Compare commits

..

21 Commits

Author SHA1 Message Date
Oliver Kieselbach d3261fb890 feat(ops): ops events for Collect Logs usage and diagnostics-upload enablement
CollectLogsQuickConfigEnabled fires when a tenant admin switches on Hosted
diagnostics upload through the session Collect Logs quick-config dialog
(PUT ?intent=collect-logs -> write source portal-collect-logs); every other
enable/disable path emits DiagnosticsUploadEnabled/Disabled (PUT, PATCH,
revert, MCP). SessionActionQueued records portal-queued server actions.
DiagnosticsUploadConfigChange is the single definition behind the
diagnosticsUploadConfigured feature flag and the flip detection. Event types
dual-registered in the OpsAlertRules catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 19:30:20 +02:00
Oliver Kieselbach b3bbcf1300 fix(agent): rotate_config applies the fetched config; diagnostics skip gates report their reason
rotate_config only stored the fetched config in RemoteConfigService and never
merged it into the running AgentConfiguration, so the portal quick-config flow
(enable diagnostics upload, then collect) ran the diagnostics gate against the
stale mode=Off and failed with a generic diagnostics_upload_failed 4 ms later.
The action now runs the same ApplyFetchedConfig path as the startup fetch and
reports config_fetch_failed when the live fetch fell back to cache/defaults.
Skipped uploads return a DiagnosticsUploadResult.Skipped with the gate name
(diagnostics_skipped_mode_off / _not_configured / _onfailure_success) instead
of null, so server_action_failed names the actual reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 19:30:00 +02:00
Oliver Kieselbach a306cd9e40 feat(security): resolve admin identity bindings automatically at grant time; per-tenant bindings panel
Granting a cross-tenant role is "type the UPN" again. AdminIdentityResolver derives the home
tenant + object id from sign-in history (UserActivity: both values came from validated tokens,
most recent sign-in wins), falls back to the UPN domain of an onboarded tenant (tid only, oid
pinned on first sign-in), and never guesses when a UPN was seen under two tenants. Only a
never-seen domain yields 422 HomeTenantUnresolved, on which the portal shows a home-tenant picker.

- IMetricsRepository.GetSignInIdentitiesByUpnAsync (filtered UserActivity scan, grant-time only)
- Grant endpoints: homeTenantId/objectId optional overrides; bindings dropped from the list bodies
- Web: identity fields and binding pills removed from the delegated / tenant-group forms;
  new collapsed "Identity bindings" panel in Tenant Management -> Edit Tenant Configuration
  (bindings homed in that tenant; inline edit / remove via global/identity-bindings)
- Tests: AdminIdentityResolverTests; grant validation now accepts an absent home tenant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 19:21:47 +02:00
Oliver Kieselbach 8ec8524252 fix(security): bind platform and delegated roles to the immutable Entra tid+oid, not the UPN string
GlobalAdmins, DelegatedAdmins and TenantGroupAssignments resolved their role from the
upn/preferred_username claim alone while the API accepts tokens from every Entra tenant, so a
genuine token minted in a foreign tenant (re-registered domain) or by a recycled UPN in the home
tenant inherited platform admin.

- New AdminIdentityBindings table: RK = UPN -> home TenantId + ObjectId; in TableNames.All,
  CriticalBackupTables and the full-restore-forbidden auth set; offboarding wipes bindings homed
  in the offboarded tenant
- AdminIdentity (upn + tid + oid) is what GlobalAdminService.GetGlobalRoleAsync and
  DelegatedAdminService.GetScopeAsync resolve on; order row -> binding -> Pro gate, fail-closed
  (no oid / no binding / tid or oid mismatch => no role), mismatches logged at Warning
- Object id pinned on the first sign-in from the bound tenant (ETag-conditional, verified against
  the stored winner); a foreign-tenant token can never claim the pin
- Grants (auth/global-admins, global/delegated-admins, tenant-group assignees) require
  homeTenantId (+ optional objectId) and answer 409 instead of re-homing a bound UPN;
  GET/PUT/DELETE global/identity-bindings is the audited rebind surface
- RequestContext.ObjectId; throttle identity prefers oid so shared UPN strings across tenants no
  longer collide in one rate-limit bucket
- Web: home tenant / object id fields on the grant and assign forms, per-UPN binding pill
- Tests: binding verdict matrix + race semantics, middleware-level denials for foreign-tenant and
  recycled-UPN tokens; OKF doc docs/backend/admin-identity-binding.md

Legacy role rows are inert until their binding is seeded - seed before deploying the backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 18:35:47 +02:00
Oliver Kieselbach c4be6d4169 fix(security): block javascript:/data: URLs in analyze-rule relatedDocs (stored XSS)
Author-controlled relatedDocs[].url was rendered verbatim as <a href> on the session
detail, diagnosis and analyze-rules pages and never validated server-side, so a
Tenant Admin could store a javascript: URL that executes in the portal origin for
any viewer — including cross-tenant Global/MSP admins.

- Web: safeHttpUrl() guard (absolute http/https only) at all four render sites;
  anything else renders as plain text.
- Backend: ValidateRelatedDocs on Create/Update — blank URLs dropped, non-http(s)
  URLs rejected with 400.
- Tests on both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:55:50 +02:00
Oliver Kieselbach d558b94cf6 updated commands 2026-08-29 17:24:45 +02:00
Oliver Kieselbach 9b4222e729 fix(ime-archive): verify MSI ProductVersion and walk primary/secondary/hotfix hosts before archiving
Microsoft serves IME from several versionless hosts carrying different rings at once
(secondary = 1.104.102.0 while hotfix = 1.105.103.0), so "new version == what the
canonical URL serves" filed a 1.104 package under 1.105.103.0. The archiver now spools
each candidate, reads ProductVersion with a managed CFB/MSI reader (Linux host, no
msiexec) and uploads only a match; no match ends as Failed:VersionMismatch. A later
sighting with a version-authoritative CSP URL re-queues an unarchived version at most
once per 24 h (MsiArchiveUpdatedAt + Queued stamp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:23:05 +02:00
Oliver Kieselbach cf76c4338f feat(vuln): manual "Refresh NVD Cache" and "Sync EPSS" with their own last-sync lines
The admin Data Sync Status only offered KEV and MSRC; the NVD stale-row refresh and the EPSS
re-score ran on the nightly timer alone. Both walks move out of the timer into
VulnerabilityCacheRefreshService (singleton, one in-flight slot per walk) so the buttons and the
nightly run share one code path and stamp the same timestamps (NvdCacheLastRefreshUtc — only for
a walk that reached every stale row; EpssLastSyncUtc). POST vulnerability/sync-nvd starts the
minutes-long, rate-limited NVD walk in the background and answers 202 (409 while running); the
page polls sync-status (nvdRefreshRunning, nvdLastRun) every 10 s meanwhile. POST
vulnerability/sync-epss runs synchronously and returns the counts. The timer skips a slot a
manual run holds instead of queueing a duplicate. Shared manifest regenerated for the two new
AdminConfiguration fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:57:55 +02:00
Oliver Kieselbach a6184f165b feat(vuln): FIRST EPSS as fourth source, act/attend/track priority, CVSS vector; never cache a refused NVD answer
EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile
and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly
sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max,
absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend =
EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged.

NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an
empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache).
NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a
process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of
paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache
projection and the row builder are now single functions shared by the live fill and the sync.

CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line;
exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms
attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:07:31 +02:00
Oliver Kieselbach d3918cb740 feat(mcp): log the protocol era per request (mcp_request line, MCP_TOOL_LOGGING-gated)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 09:13:00 +02:00
Oliver Kieselbach 15722a3885 fix(mcp): log the CIMD success path so metadata-document logins are positively visible
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 09:06:09 +02:00
Oliver Kieselbach 1a4e13f0e9 feat(mcp): implement MCP spec 2026-07-28 (SDK v2 dual-era handler) and Client ID Metadata Documents
Wire protocol: replace @modelcontextprotocol/sdk 1.30 with @modelcontextprotocol/server + /node 2.0.0
(zod 4). One per-request factory (mcp-server-factory.ts, extracted from index.ts) serves both eras
through mcp-http.ts: 2026-07-28 clients via createMcpHandler (legacy:'reject', JSON response mode,
server/discover, Mcp-Method routing), 2025 clients through the existing stateless transport with
enableJsonResponse — routed on the SDK's own isLegacyRequest predicate, because the built-in stateless
fallback would answer every current client with SSE and lose the gzip win. Capabilities now declare
listChanged:false explicitly; cacheHints stamp ttlMs/cacheScope:'private' on discover/list results
(5 min) and resource reads (60 min) — the catalog is role-dependent, so never public.

Authorization: advertise client_id_metadata_document_supported; an https client_id is a Client ID
Metadata Document resolved by cimd.ts (URL-shape checks, SSRF gate on every resolved address before any
request, no redirects, 5 s / 16 KB caps, exact client_id match, bounded redirect_uris, 10-60 min cache).
The requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR
client_ids remain the fallback. Field limits moved to oauth-limits.ts (no import cycle).

Tests: mcp-http.test.ts drives the real v2 client over loopback HTTP in both eras and pins role parity;
cimd.test.ts / oauth-cimd.test.ts cover recognition, SSRF, validation, cache and the router paths.
Package 1.5 -> 1.6. OKF doc docs/mcp/protocol-eras-and-cimd.md; mcp-oauth-flow.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 08:43:58 +02:00
Oliver Kieselbach c39092bf72 ci(mcp): image tag unique per run so docs-only redeploys roll a new revision
A redeploy at an unchanged code sha (the documented way to refresh the
baked-in search_docs corpus) built an image with the identical tag;
az containerapp update saw the same image reference and created no
revision, so the verify step timed out against the old build (run
33215583943). Tag = sha-run_number; build commit/version stay separate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 00:17:04 +02:00
Oliver Kieselbach 494744ad3f feat(web): progress portal follows Windows 365 Cloud PC first-connect enrollments
Cloud PC users know the device name the Windows App shows, and the lookup
already accepts device names, so there is no reason to exclude them. The
monitored enrollment starts at first connect in Account Setup (v1 rail,
phases 4 -> 5 -> 7; device phases 1-3 run headless and are never declared),
so the Cloud PC layout lists only the steps the user can actually watch:
First sign-in / Account setup / Installing apps / Finalizing setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 00:05:59 +02:00
Oliver Kieselbach a68c096a26 feat(web): progress portal follows every enrollment scenario (Device Preparation, self-deploying, pre-provisioning)
The end-user page hardcoded the seven Autopilot v1 ESP phases and gated its live
app panel on currentPhase 3/5 — phases a Device Preparation session never
declares — so DevPrep users saw 0 % and no app progress until Finalizing.
Steps now derive from the shared resolvePhaseLayout (v2 layout, skip-user
layout), the v2 app step is promoted from app-event evidence, and the status
vocabulary maps AwaitingUser / parked pre-provisioned devices to a waiting
state and Incomplete to neutral — only Failed stays red. Cloud PCs are named
as unsupported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 21:30:04 +02:00
Oliver Kieselbach bf156cf862 fix(backend): CMTrace skew tripwire ignores backlog-replay bursts via per-sample grid conformity
CmTraceTimeSkewRegression fired 7x in its first soak week; every checked case
was a relaunched agent re-tailing the IME log and uploading one burst holding
most of the session's IME samples with a continuum of ages whose median hit the
15-minute grid by chance. The tripwire now also requires >= 80 % of the IME
deltas (relative to the other side's median) to individually sit within 2 min of
some grid multiple; era-mixing regressions still fire. Fraction is carried in the
ops event message and details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:42:51 +02:00
Oliver Kieselbach 0eaa3527f9 feat(validation,web): device association validator generally available
Microsoft released Windows Autopilot device association on 2026-08-25. The
DevPrep association lookup leaves shadow mode and becomes an accepting method
of the device-validation gate (after Autopilot and Corporate Identifier, before
the Cloud PC fallback; ValidatedBy = DeviceAssociation, transient -> 503). The
toggle is no longer Global-Admin-only: backend PUT revert and GaOnlyFields
dropped, settings toggle shown to every tenant admin under Device Preparation
with the shared consent rule. Landing hero carries a release pill linking to
the docs; dashboard tip added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 21:20:57 +02:00
Oliver Kieselbach 5055db2de1 feat(rules): detect installer-mutex app failures (0x80070652) and SWG-blocked diagnostics uploads
Two analyze rules born from the pilatus-aircraft diagnosis (2026-08-27):

- ANALYZE-APP-017 "App Install Blocked by Another Installer (0x80070652)":
  fires on app_install_failed with hresultFromWin32 -2147023278
  (ERROR_INSTALL_ALREADY_RUNNING); confidence boosts for MSI exit code
  1618 and for an ESP failure settling with the same code. The 08-26
  burst failed 16 sessions deterministically at ~21 min (IME's fixed
  msiexec-busy retry budget) right after a preceding installer left the
  Windows Installer mutex held - previously only generic
  "Enrollment Failed" fired, naming no cause.
- ANALYZE-NET-001 "Diagnostics Upload Blocked by Web Security Gateway":
  fires on diagnostics_upload_failed answered by an HTML block page
  carrying a recognizable security-product marker (McAfee/Skyhigh,
  Zscaler, Netskope, ...). OnFailure diagnostics are lost exactly on the
  sessions that need them; plain HTML errors without a vendor marker
  (stale/stopped endpoint) stay excluded on purpose.

Both validated via MCP validate_rule (0 errors) and dry-run with
test_analyze_rule: fire on the burst session (confidence 100 / 90),
silent on a timeout-shaped failure and on a succeeded session that
carried unrelated app_install_failed events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 19:40:17 +02:00
Oliver Kieselbach 040e411958 feat(classification,calibration): rule 5a completed-assumed, max-lifetime grace skip, drift-radar floors
First real calibration read (2026-08-27, four days instrumented) drove three
data-backed changes:

- Rule 5a: past grace (or with the agent provably gone), Device Setup done +
  real-user desktop + zero app_install_failed reconciles to Succeeded
  "completed (assumed)" (r5_assumed). r5-Incomplete devices re-enrolled at
  2.6% - the succeeded background, not the ~29% failure level - and the
  sampled desktop half had clean app records. No desktop or any failed app
  stays Incomplete; rule 4 remains the immediate desktop+Hello reconcile.
- Rule 5 skips the AwaitingUser grace once either max-lifetime watchdog shape
  (enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) is in
  the stream: all five observed maxlife:r5_awaiting parks expired unhealed
  while live-agent parks upgraded 7/26. The maxlife ingest appends its
  trigger event to the read stream so the fact is never missed; late
  telemetry still heals a terminal verdict. WhiteGlove rule 1b keeps its
  park (its one episode healed).
- Drift radar: MinWindowHits raised to 10 (a 3-hit episode survived as
  noise), per-path share regression now needs >=5 baseline hits (new or
  renamed vocabulary cannot "regress"), and rule-shaped legacy:* paths count
  into the silence/classifier-verdict group sums on both sides of the
  2026-08-23 instrumentation cutover - dissolving the lift-124 silence
  artifact. Episodes below the fire floors clear themselves.

Backend only; the new path appears in the calibration matrix dynamically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 18:10:19 +02:00
Oliver Kieselbach 42c45cf567 feat(admin,backend): find a tenant by its notification or contact email
A delivery log names only the recipient, never the tenant the welcome mail was
for. The tenant console's search now matches the notification and contact
address as well as domain and tenant id; the quoted exact mode is unchanged, and
no other address is searchable (an admin UPN never receives platform mail and
would only widen the PII surface of the list).

The addresses live in PreviewWhitelist, not on the tenant config, so a new
GA/Reader-only GET /api/preview/notification-emails returns them as one map --
a single cross-partition scan instead of a point read per tenant. Both sides
join on the lowercased tenant id (address rows carry the JWT tid, a config
PartitionKey's casing is not guaranteed) and matching is case-insensitive,
because stored addresses keep the casing the user typed.

The map is best-effort in the portal: a failed read costs only the
search-by-address, never the tenant list. Addresses stay out of the list rows --
they are a search key, and remain visible in the tenant editor.

Match rules moved out of the component into tenantSearch.ts with unit pins, and
a catalog test pins that the plural route does not resolve to the per-tenant
entry one character away (which would demand a tenantId the route does not
carry, and hand a delegated caller a rescue path into the whole map).

Deploy: backend before web.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:41:05 +02:00
Oliver Kieselbach b31256c748 fix(activation): capture the welcome-mail address instead of redirecting past it
The activation page held the only address field in the product, and its redirect
effect fired on the first poll after auto-approve — roughly 60 seconds after
signup. Anyone who had not typed by then lost the field, no notification-email row
was ever written, and TrySendWelcomeEmailAsync deferred a mail that nothing would
send later. Nine of twenty-one August signups got a welcome mail; the last one
went out on the 21st.

It stayed hidden because every "not sent" branch logged at Information, and worker
application logs never reach Application Insights below Warning.

- Activation page holds the redirect until the address is saved or deliberately
  skipped, and switches to an "Activated" state while it waits. Visitors who
  arrive at an already-activated tenant are passed straight through as before.
- Recipient resolution falls back from the activation-page address to the tenant
  contact address. The signup admin UPN is deliberately not a fallback: those
  accounts often have no mailbox and the bounces cost sender reputation.
- WelcomeEmailSent / WelcomeEmailSkipped / WelcomeEmailFailed ops events make
  every outcome visible, dual-registered in OPS_EVENT_TYPES.

Three defects in the same path, found while fixing it:

- The sent-marker was consumed before the send and never released on refusal, so
  one provider hiccup suppressed a tenant's welcome mail permanently. It now
  records delivery, not an attempt.
- A missing provider key logged at Debug, hiding the one condition that stops
  every mail at once. Now Warning.
- The Global Admin resend endpoint reported success regardless of what the
  provider answered. It is the manual fallback for exactly this failure, so it now
  returns 502 and leaves the marker free when the message is refused.

The welcome mail is also no longer fire-and-forget: a discarded Task can be torn
down when the invocation ends, and its result is what the ops event reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:32:52 +02:00
187 changed files with 10237 additions and 1437 deletions
+8 -3
View File
@@ -11,7 +11,11 @@ env:
ACR_NAME: autopilotmonitoreuacr
CONTAINER_APP_NAME: autopilotmonitor-mcp
RESOURCE_GROUP: ${{ secrets.AZURE_MCP_RESOURCE_GROUP }}
IMAGE_TAG: ${{ github.sha }}
# Unique per RUN, not per commit: the docs corpus is baked into the image, so a
# docs-only redeploy at an unchanged code sha must still produce a new image
# reference — with a sha-only tag `az containerapp update` saw the same image
# and silently created no revision (2026-08-28).
IMAGE_TAG: ${{ github.sha }}-${{ github.run_number }}
HEALTH_URL: https://mcp.autopilotmonitor.com/health
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json
@@ -28,8 +32,9 @@ jobs:
# baked into the image as the search_docs corpus (see the Dockerfile). No
# token is needed. Checking it out here rather than vendoring it keeps the
# docs single-sourced — but it also means EDITING THE DOCS DOES NOT UPDATE A
# RUNNING SERVER; this workflow must be re-run. /health reports the SHA
# captured below so that staleness is checkable.
# RUNNING SERVER; this workflow must be re-run (IMAGE_TAG is per run so the
# re-run actually rolls a new revision). /health reports the SHA captured
# below so that staleness is checkable.
- name: Check out documentation bundle
uses: actions/checkout@v7
with:
+5 -1
View File
@@ -94,7 +94,11 @@ batch, the counter reconcile's existing event-partition scan additionally collec
rest (`CmTraceSkewTripwire`, hooked in `EventIngestProcessor`). If
`median(Δ_IME) − median(Δ_other)` is a clean 15-minute multiple (residual < 2 min — the
same constants as the anchoring grid guard above) with ≥ 20 samples over ≥ 3 distinct
upload batches per side, a `CmTraceTimeSkewRegression` ops event fires (category Agent,
upload batches per side, and ≥ 80 % of the individual IME deltas (relative to the other
side's median) themselves sit within 2 min of some grid multiple — added after the 2026-08-28
soak, when relaunched agents re-tailing the IME log produced one burst holding most of a
session's IME samples with a continuum of ages whose median hit the grid by chance (7 false
fires in a week, 0 true) — a `CmTraceTimeSkewRegression` ops event fires (category Agent,
warning), carrying all numbers plus the session's `sourceOffsetOrigin` histogram.
Bias-dominated sessions are suppressed (writer-declared offsets cannot be an anchoring
regression); `measuredWriterOffsetMinutes` is never consulted (sticky after era
+165
View File
@@ -0,0 +1,165 @@
---
type: Concept
title: Admin Identity Binding — tid + oid behind every cross-tenant-role UPN
description: Why a GlobalAdmins or DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant, the AdminIdentityBindings table that pins each such UPN to its immutable home tenant id and object id, the fail-closed resolution order in both role services, the first-sign-in object-id pin, and the explicit rebind surface.
resource: src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs
tags:
- backend
- security
- authorization
- multi-tenant
- global-admin
- delegated-admin
timestamp: 2026-08-29T00:00:00+02:00
---
# Problem
The API is a multi-tenant Entra application on purpose: `AuthenticationMiddleware` accepts a token from
any tenant (issuer prefix `login.microsoftonline.com/` or `sts.windows.net/`), and signature, audience,
lifetime and algorithm are validated strictly. Tenant-member roles (`TenantAdmins`) are keyed on
`(tid, upn)` and therefore bound to the verified tenant.
The two cross-tenant tiers were not. `GlobalAdmins` (GlobalAdmin / GlobalReader) and the delegated MSP
tier (`DelegatedAdmins` + `TenantGroupAssignments`) resolved their role from the **UPN string alone**
(`upn`, falling back to `preferred_username`). Both claims are mutable and reusable across tenants:
* **Domain re-registration**: if the domain behind a platform admin's UPN lapses or moves, whoever
verifies it in a fresh tenant can mint the identical UPN; the token is genuine, only `tid` differs.
* **UPN recycling**: a User Administrator in the home tenant can re-assign the UPN to another account;
same `tid`, different `oid`.
* **MSP variant**: the delegated tier was gated only on the caller's home tenant being Pro — which any
tenant can self-provision via the trial endpoint.
Any of these yielded full platform scope: raw table dumps including `TenantConfiguration` secrets,
tenant offboarding, GA management. Microsoft's own guidance (post-nOAuth) is explicit: authorization
data must be keyed on the immutable `tid` + `oid` pair, never on `upn` / `preferred_username`.
# Mechanism
## One binding per UPN
`AdminIdentityBindings` (PK `Bindings`, RK = lowercase UPN) holds, for every UPN that carries a
cross-tenant role, the identity that may use it:
| Column | Meaning |
| --- | --- |
| `TenantId` | The admin's **home** Entra tenant — must equal the JWT `tid`. Mandatory at grant time. |
| `ObjectId` | The admin's Entra object id — must equal the JWT `oid`. Empty until pinned. |
| `BoundBy`, `BoundDate` | Operator provenance. |
| `ObjectIdPinnedDate` | When the object id was fixed (grant time, or the first matching sign-in). |
A single table rather than columns on three role tables: a UPN string belongs to exactly one identity
at a time, a person may hold a GlobalAdmins row *and* delegated assignments, and the role rows stay
what they are — grants. The binding says whose grants they are.
## Resolution order (both role services)
`AdminIdentity` is the record `(Upn, TenantId, ObjectId)` built from a validated principal; it is
`null` when any of the three claims is missing (app-only tokens, foreign IdP shapes). Both
`GlobalAdminService.GetGlobalRoleAsync(AdminIdentity?)` and
`DelegatedAdminService.GetScopeAsync(AdminIdentity?)` follow the same order:
1. `null` identity → no role, no storage read.
2. Resolve the **row** by UPN (cached 30 s, unchanged from before).
3. No row / empty scope → return; the binding is not even read. Ordinary tenant users cost one cached
read and never produce a binding log line.
4. `AdminIdentityBindingService.IsBoundAsync(identity)` → false ⇒ no role / empty scope.
5. Delegated only: the Pro home-tenant entitlement gate, **after** the binding check — an unbound caller
never triggers an edition lookup.
`IsBoundAsync` is the single choke-point: no binding ⇒ false; `TenantId` mismatch ⇒ false (checked
before anything else, so a foreign-tenant token can never claim the pin below); pinned `ObjectId`
mismatch ⇒ false; unpinned binding + matching tenant ⇒ **pin** the caller's `oid` and verify against
what was actually stored afterwards. Every false outcome is logged at Warning with its reason
(`[IdentityBinding] TENANT MISMATCH …` / `OBJECT-ID MISMATCH …` / `holds no identity binding`), the
one level the worker forwards to Application Insights; matches are silent. Pins are logged at Warning
once — they are one-off, operator-relevant events.
The pin is a conditional update in the repository (`TryPinIdentityObjectIdAsync`: only onto an unpinned
row homed in the caller's tenant, ETag-guarded; a 412 re-reads and the caller compares against the
winner). Two accounts racing the first sign-in inside the home tenant therefore leave exactly one bound.
Row lookups stay cached by UPN; the binding has its own 30 s cache keyed by UPN and is invalidated on
pin, rebind and removal. A rebind on one scaled-out instance converges on the others within seconds.
## Grants bind first — and resolve the identity themselves
Granting is "type the UPN". The three grant endpoints (`POST auth/global-admins`,
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`) accept `homeTenantId` /
`objectId` only as optional overrides; normally `AdminIdentityResolver` supplies them:
1. **Sign-in history** (`UserActivity`, filtered on the UPN): the person has signed in before ⇒ tid + oid
are known, both from validated tokens. The most recent sign-in wins for the oid. A UPN seen under
**more than one tenant** is never auto-resolved — that is the attack shape the binding exists for.
2. **UPN domain → onboarded tenant** (`TenantConfiguration.DomainName`, exactly one match): tid only; the
oid is pinned on the first sign-in.
3. Neither ⇒ **422 `HomeTenantUnresolved`**; the portal then shows a home-tenant picker (onboarded
tenants) and retries with `homeTenantId`. This is the only manual step, and only for a UPN whose
domain the platform has never seen.
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` then take the resolved
`homeTenantId` / `objectId` and call `EnsureBoundAsync` **before** writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
supplied object id equals the pin or upgrades an unpinned one), and throws
`IdentityBindingConflictException` for a different tenant or a different pinned object id — the
functions map that to **409**. A grant can therefore never silently re-home a UPN.
Why the object id may be unknown: a person who has never signed in has no history to resolve from, and
the operator granting an external MSP admin has no directory access to the partner tenant. The first
sign-in from the bound tenant pins it; from then on a recycled UPN in the same tenant is refused.
## Explicit rebind surface
| Route | Policy | Purpose |
| --- | --- | --- |
| `GET global/identity-bindings` | GlobalReadOrAdmin | Audit which tenant / object id each grant is usable from. |
| `PUT global/identity-bindings/{upn}` | GlobalAdminOnly | Replace the binding: move a UPN to another tenant, or re-pin after a legitimate account re-creation (omit `objectId` to clear the pin). |
| `DELETE global/identity-bindings/{upn}` | GlobalAdminOnly | Make every role row of the UPN inert without touching the rows. Self-removal is refused. |
Both mutations are audited under the binding's home tenant (`AdminIdentityBinding` entity, previous
values in the details) and logged at Warning. The portal surfaces this in **Tenant Management → Edit
Tenant Configuration → Identity bindings** (collapsed): the bindings homed in that tenant, with inline
edit (re-home / re-pin; empty object id ⇒ re-pinned on the next sign-in) and remove. The grant forms
themselves carry no identity fields — the binding is a background property of the grant.
## Lifecycle and secondary effects
* `AdminIdentityBindings` is in `TableNames.All`, in `CriticalBackupTables.All` and in
`AuthTablesFullRestoreForbidden` (single-row restore only, like the other auth tables).
* Tenant offboarding wipes bindings whose `TenantId` is the offboarded tenant (property-only bucket):
once a home tenant is gone, its admins' grants become inert.
* `RequestContext.ObjectId` carries the JWT `oid`; `AdminIdentity.FromRequestContext` serves the
handlers that resolve roles outside the middleware (SignalR group join/leave, health checks).
* The throttle identity (`GetCallerId`) now prefers `oid` over the UPN, so two accounts in different
tenants that share a UPN string no longer share a rate-limit bucket. Audit and presence rows keep the
UPN as their display key; they already carry the tenant id.
* Legacy role rows without a binding are **inert, not grandfathered**. Rolling this out therefore means
seeding the binding rows for the existing admin UPNs (their `tid` and `oid` are recorded per login in
`UserActivity`) *before* the backend deploy — an unseeded platform admin locks themselves out of every
GlobalAdminOnly route, including the rebind surface.
# Examples
A GlobalAdmin row for `admin@vendor.example` bound to tenant `1111…` with object id `aaaa…`:
| Token | Result |
| --- | --- |
| `tid=1111… oid=aaaa… upn=admin@vendor.example` | GlobalAdmin |
| `tid=2222… oid=aaaa… upn=admin@vendor.example` | no role — `TENANT MISMATCH` warning |
| `tid=1111… oid=bbbb… upn=admin@vendor.example` | no role — `OBJECT-ID MISMATCH` warning |
| `tid=1111… upn=admin@vendor.example` (no `oid`) | no role — identity incomplete |
Same row, binding without an object id: the first `tid=1111…` sign-in pins its `oid` and is admitted; a
`tid=2222…` sign-in before that is refused **and does not pin**.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Security/AdminIdentity.cs` — the identity record and its factories.
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs` — verdict, pin, grant-time binding, rebind; the table entity.
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs` — sign-in-history / UPN-domain resolution at grant time (`AdminIdentityResolverTests`).
* `src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx` — the per-tenant inspect/correct panel.
* `src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs` — resolution order.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs` — `TryPinIdentityObjectIdAsync` and the binding CRUD.
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs` — the rebind surface and the shared `IdentityBindingRequest.Validate`.
* `src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityBindingServiceTests.cs`, `AdminIdentityBindingAuthorizationTests.cs` — verdict matrix, race semantics, and the middleware-level denials for foreign-tenant and recycled-UPN tokens.
* [Client-Certificate Tenant Binding](client-cert-tenant-binding.md) — the same "chain-valid is not tenant-bound" argument on the device side.
+114
View File
@@ -0,0 +1,114 @@
---
type: Concept
title: IME Installer Archive (version-verified, multi-host)
description: How every fleet-observed Intune Management Extension build is archived as its MSI - the ime_agent_version sighting that queues the job, the candidate hosts, the ProductVersion check that keeps a wrong ring's build out of the version folder, the re-queue on later sightings, and the manual skill-side backfill.
resource: src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiver.cs
tags:
- backend
- ime
- archive
- blob
- queue
timestamp: 2026-08-29T17:30:00+02:00
---
# IME Installer Archive
The platform keeps every Intune Management Extension build it has ever seen in the fleet
as the original installer, so any IME version can be decompiled and diffed later
(`ime-decompiles` repository, `/ime-decompile` skill) even after Microsoft's CDN has moved
on. Two stores:
| Store | Content | Written by |
| --- | --- | --- |
| Blob container `ime-archive` (private, kept by design, not tenant-scoped) | `{version}/IntuneWindowsAgent.msi` (write-once) + `{version}/provenance.json` (derived, overwritable) | `ImeMsiArchiver` via the `ime-msi-archive` queue; `/ime-decompile` skill as manual backfill |
| Table `ImeVersionHistory` (partition `Global`, RowKey = version) | first/last seen, session count, `MsiArchiveStatus`, `MsiArchiveUpdatedAt`, `MsiArchiveBlobPath`, `MsiSha256`, `MsiBytes`, `MsiSourceUrl` | `RecordImeVersionAsync` (sighting), `UpdateImeVersionArchiveInfoAsync` (archive outcome / queued stamp) |
# Schema
## Sighting → job
`EventIngestProcessor` looks at the batch's `ime_agent_version` event (`agentVersion`, plus
the agent's CSP-registry enrichment `msiDownloadUrl` / `msiMatchedBy` when present).
`RecordImeVersionAsync` returns an `ImeVersionSighting`:
* `IsNew` — the insert succeeded: first fleet-wide sighting → ops event + enqueue.
* otherwise the row's `MsiArchiveStatus` / `MsiArchiveUpdatedAt`, read in the same point
read that bumps `SessionCount` (no second table read).
A known version is **re-queued** (`ImeMsiArchiver.ShouldRequeueOnSighting`) only when all
of these hold: the event URL passes the allowlist **and** `msiMatchedBy == productVersion`
(the CSP row's ProductVersion equals the observed version, so the URL is authoritative),
the status is neither `Archived` nor `Failed:BadVersion`, and the last archive activity is
older than 24 h (`RequeueBackoff`) or never happened. The ingest path then merges
`MsiArchiveStatus = Queued` (stamping `MsiArchiveUpdatedAt`) before enqueueing, so the other
sessions of that version see "in flight" and do not pile on. Worst case for a version no
host serves any more: one walk over the hosts per day, not one per enrollment.
## Candidate hosts and the version check
Microsoft distributes IME from several versionless URLs that carry **different rollout
rings at the same time**. Observed 2026-08-29: `imeswdb-afd-secondary` served 1.104.102.0,
`imeswda-afd-primary` 1.105.101.0, `imeswda-afd-hotfix` 1.105.103.0. "A new version is by
definition what the canonical URL serves right now" is therefore false, and the first
version of the archiver filed a 1.104.102.0 package under `1.105.103.0/` (fixed by hand:
blob overwritten, row merged).
The archiver walks `BuildCandidateUrls(eventUrl)`: the allowlisted event URL first (HTTPS,
host `*.manage.microsoft.com`, filename exactly `IntuneWindowsAgent.msi`), then
`FallbackMsiUrls` = primary, secondary, hotfix — de-duplicated. For each candidate:
1. Download with the admin size cap (Content-Length preflight + mid-stream cap) into a
delete-on-close temp file while SHA-256 hashing.
2. Read the package's `ProductVersion` with `MsiProductVersionReader` — a pure managed OLE
compound-file reader (v3 512-byte and v4 4096-byte sectors, DIFAT/FAT/mini-FAT, MSI
stream-name decoding, `_StringPool`/`_StringData` incl. long strings and holes, the
column-major `Property` table with 2- or 3-byte string refs). It never throws; anything
unreadable is `null`. Needed because the Function host is Linux — no `msiexec`.
3. `VersionsMatch(observed, productVersion)` (component-count tolerant: `1.105.103` ==
`1.105.103.0`). Mismatch or unreadable → log, record the attempt, next candidate.
4. Match → write-once upload (If-None-Match:*), then `provenance.json` with
`productVersion`, the chosen `url`, `urlFromEvent`, hash/size and the full `candidates`
walk (`url`, `outcome` ∈ match / version-mismatch / too-large / download-failed /
timeout, `productVersion`).
Outcome precedence when no candidate archived: any transient failure (download/timeout)
→ `Failed:Download` / `Failed:Timeout`, retryable via the queue's visibility ladder (the
down host might have had it); every candidate over the cap → `Failed:TooLarge`; otherwise
`Failed:VersionMismatch` — **not** retryable by the queue (retrying in minutes changes
nothing), left to the sighting re-queue above. Storage/unexpected errors stay
`Failed:Error`, retryable. A 409 on the write-once upload means an earlier attempt
archived the version: the provenance sidecar is healed from the archived bytes (hash,
size, ProductVersion) if missing — this is also how a manual skill backfill turns a null
row into `Archived` on the next authoritative sighting.
## Manual path (skill)
`/ime-decompile` downloads from the blob (`ime-blob.sh download`), and
`fetch-ime-decompile.py` names the decompile folder after the **actual** FileVersion of
the extracted binaries — so the decompile repo can never be poisoned, only the blob folder.
When the blob's version mismatches, look for `msiDownloadUrl` in *other* sessions of the
version (the first-seen session often lacks the enrichment) and fetch from that host.
`ime-blob.sh upload … --overwrite` replaces a blob known to hold the wrong build; merge the
row's `MsiSha256` / `MsiBytes` / `MsiSourceUrl` afterwards.
# Examples
* First sighting of 1.105.103.0 with no URL: primary → 1.105.101.0 (mismatch), secondary →
1.104.102.0 (mismatch), hotfix → 1.105.103.0 (match) → archived from hotfix, provenance
lists all three.
* Hotfix host down during the walk, others mismatching → `Failed:Download`, queue retries.
* Version 1.101.111.0 (predates the archiver, row status null): a session whose CSP row
matches by ProductVersion arrives → status `Queued`, job runs; if no host serves it any
more → `Failed:VersionMismatch`, next try no sooner than 24 h later.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiver.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/MsiProductVersionReader.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/Ime/ImeMsiArchiveQueueWorker.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs` (ime_agent_version block)
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` (`RecordImeVersionAsync`, `UpdateImeVersionArchiveInfoAsync`)
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeMsiInstallSourceProbe.cs` (event enrichment)
* `.claude/commands/scripts/ime-blob.sh`, `.claude/commands/scripts/fetch-ime-decompile.py`
* Tests: `ImeMsiArchiverTests`, `MsiProductVersionReaderTests` (synthetic packages + local sweep over `ime-files/`)
+39 -9
View File
@@ -10,7 +10,7 @@ tags:
- classification
- whiteglove
- self-deploying
timestamp: 2026-08-23T18:00:00+02:00
timestamp: 2026-08-27T20:00:00+02:00
---
# Silent-Session Classification
@@ -30,7 +30,10 @@ so a session gets the same verdict regardless of which path noticed the silence
subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFailure`,
`HasEnrollmentComplete` vs. `HasTerminalComplete` (the Part-1 `whiteglove_complete`),
`HasAgentEmergencyBreak`, `DesktopArrived`, `HelloResolved`, `HelloPolicyDisabled`,
`SkipUserEsp`, RealmJoin detection/resolution. Session-row flags are passed alongside:
`SkipUserEsp`, RealmJoin detection/resolution, `HasAppInstallFailure` (any
`app_install_failed`), and `HasAgentMaxLifetimeTimeout` (either max-lifetime shape:
`enrollment_failed`/`agent_timeout` or `agent_shutting_down`/`max_lifetime` — the agent is
provably gone). Session-row flags are passed alongside:
`IsPreProvisioned` + `ResumedAt` (WhiteGlove Part 2) and `IsSelfDeployingProfile`
(registry-confirmed `CloudAssignedOobeConfig 0x20|0x40`, set at registration).
@@ -44,7 +47,9 @@ subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFail
| 2 | Account Setup all-succeeded, or `enrollment_complete`, or `whiteglove_complete` on a **non-resumed** session | Succeeded |
| 3 | `HasAgentEmergencyBreak` | Incomplete immediately (agent self-destructed, nothing can arrive) |
| 4 | `DesktopArrived` + (Hello terminal, or Hello disabled + SkipUserEsp) | Succeeded (user provably finished) |
| 5 | Device Setup done | AwaitingUser within grace, Incomplete past grace |
| 5 | Device Setup done + within grace + **no** max-lifetime watchdog observed | AwaitingUser |
| 5a | Device Setup done + past grace (or watchdog observed) + `DesktopArrived` + no `app_install_failed` | **Succeeded** — completed (assumed), `r5_assumed` |
| 5b | Device Setup done, otherwise past grace (or watchdog observed) | Incomplete |
| 6 | otherwise | Incomplete |
Grace = `ResolveGraceHours(SessionGraceHours, AbsoluteMaxSessionHours)` (~51h default),
@@ -65,7 +70,12 @@ budget on a Wi-Fi link still associating), and rule 5 labeled finished kiosks
Incomplete turned a working rollout into a red wall. Other self-deploying tenants completed
normally in the same window, so this is a classification gap, not a profile-wide agent bug.
Agent-side companion fix: `SessionRegistrationHelper` now waits up to 15s for a network link
and retries 6× (~62s backoff) instead of 5× (~30s).
and retries 6× (~62s backoff) instead of 5× (~30s). Measured 2026-08-27 (builds
2.0.1428/1429, both carrying the fix): the reconcile share did not move, and the backend saw
no registration attempt from any relaunch — no success, no 4xx, zero `register:superseded`
in the tenant — so the relaunch never reaches the network at all (device powered off after
ESP, or the relaunch never runs). The sweep reconcile is the permanent completion path for
this profile, not a stopgap.
## Callers (all pass both gate inputs)
@@ -78,13 +88,33 @@ and retries 6× (~62s backoff) instead of 5× (~30s).
| Agent stall probe (`session_stalled`) | `EventIngestProcessor.TryMapStallToWhiteGloveAwaitingUserAsync` | Only the WG gate (1b); a stall probe on a self-deploying device with Device Setup done means the agent is alive and something else blocks — stays Stalled. |
| Admin retro-reconcile | `LegacyReclassificationService` | Modes `legacy_timeouts`, `pending_orphans`, `self_deploying_silent` (Incomplete/AwaitingUser/Stalled rows with the profile flag, heal-only to Succeeded, admin-marked rows skipped). `POST /api/maintenance/reclassify-legacy?mode=…&dryRun=false&tenantId=…&maxSessions=…`, dry-run by default. |
## Known gap (decided, not built)
## Why rule 5a exists (calibration read 2026-08-27)
The verdict-calibration matrix showed r5-Incomplete devices re-enrolling at 2.6 % within
7 days — the succeeded background (`agent:complete_soft` sits at 2.8 %), not the ~29 %
failure level — consistently across tenants, and half the sampled sessions were healthy
desktops with a clean app record whose agent died before the Hello terminal (some with the
agent's own "AccountSetup treated as complete" synthesis as the literally last event). Past
the full grace, "the user phase is still running" is no longer a possible explanation, so
desktop + zero `app_install_failed` + no explicit failure now reconciles to Succeeded
("completed (assumed)") instead of a red Incomplete. Sessions without a desktop (user never
began sign-in: battery-drained, powered off, max-lifetime) and sessions with any failed app
stay Incomplete — "completed" must not overclaim. Rule 4 remains the IMMEDIATE reconcile
(desktop + Hello proof); 5a only fires where waiting is over. Desktop arrival alone is still
rejected as a live completion signal.
## Closed gap: max-lifetime AwaitingUser (2026-08-27)
After max-lifetime the agent writes `enrollment-complete.marker` and self-destructs, so an
AwaitingUser verdict from that path can never heal — it is a 51h countdown to Incomplete.
The emergency-break path (rule 3) already terminalizes immediately; max-lifetime still goes
through the grace window by design ("user simply absent"). Revisit if customers read
AwaitingUser as "still possible".
AwaitingUser verdict from that path could never heal — the calibration correction stream
confirmed it: all five observed `maxlife:r5_awaiting` episodes expired to Incomplete
unhealed, while live-agent `sweep:r5_awaiting` parks upgraded 7 of 26 to Succeeded. Rule 5
therefore skips the grace once `HasAgentMaxLifetimeTimeout` is in the stream and decides the
terminal fork (5a/5b) immediately; the max-lifetime ingest appends its trigger event to the
read stream so the fact is never missed, and late straggler telemetry still heals a terminal
verdict via `TryLateTelemetryReconcileAsync`. WhiteGlove rule 1b deliberately keeps its
park — sealed/boxed devices do heal (the observed `maxlife:r1b_awaiting` episode resolved to
Succeeded).
# Examples
@@ -58,6 +58,24 @@ The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantI
always sends (explicit intent) and consumes the marker best-effort so the automatic
paths never duplicate afterwards.
## Finding the tenant behind an address
The provider's delivery log names only the recipient — never the tenant it belonged to. The
reverse lookup is therefore part of the tenant console: its search matches `tenantId`,
`domainName`, `ContactEmail` **and** the notification address. No other address is searchable
(an admin UPN never receives platform mail and would only widen the PII surface of the list).
The addresses live in a different table than the tenant configs the list is built from, so the
console loads them as a map from `GET /api/preview/notification-emails` (GA + read-only Global
Reader, `excludeDelegated`) — one cross-partition scan of `RowKey eq 'notification-email'`
instead of a point read per tenant. Keys are lowercased on both sides of the join: the address
rows are written from the JWT `tid` (lowercase) while a config PartitionKey's casing is not
guaranteed. Stored addresses keep the casing the user typed, so matching is case-insensitive.
The map is **best-effort** in the portal: it powers only the search-by-address, so a failed read
degrades that one capability instead of taking the tenant list down. The addresses are not
rendered in the list — they are a search key, and stay visible in the tenant editor.
## Mail transport
Both transactional mails (welcome, post-offboarding farewell) go through the
@@ -118,3 +136,4 @@ properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on
* `src/Backend/AutopilotMonitor.Functions/Services/EmailTemplateService.cs` — override resolution, placeholder rendering, cache
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs` — GA endpoints (read / override / reset / test send)
* `src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx` — portal editor, preview frame, test send
* `src/Web/autopilot-monitor-web/app/admin/components/tenantSearch.ts` — the console's match rules (searchable fields, quoted exact mode, lowercased join)
+19 -7
View File
@@ -9,7 +9,7 @@ tags:
- classifier
- calibration
- regression-radar
timestamp: 2026-08-23T18:00:00+02:00
timestamp: 2026-08-27T20:00:00+02:00
---
# Verdict Calibration (classifier thermometer)
@@ -114,15 +114,18 @@ and is wiped with the tenant on offboarding.
## Drift radar — `VerdictCalibrationRadar`
Same statistics as the [rule regression radar](rule-regression-radar.md) (7-day window vs the
prior 28 days, ≥ 5 window hits, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.
RateIncreaseSeparated`), anchored on yesterday, over every partition with rows (each tenant
and `global`):
prior 28 days, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.RateIncreaseSeparated`), with
stricter floors since the first-month tuning (2026-08-27): **≥ 10 window hits** (verdict
shares on single-digit counts are noise — a 3-hit episode survived as such), and a per-path
share regression additionally needs **≥ 5 baseline hits** — a path with no established
baseline is new (or renamed) vocabulary, not a regression. Anchored on yesterday, over every
partition with rows (each tenant and `global`):
| Kind | Signal | Re-arm |
| --- | --- | --- |
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded) | share < 1.5× baseline, the path stops occurring, or the path is no longer eligible |
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one) | same |
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts, ≥ 20 verdicts | < 15 % or < 20 verdicts |
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded per-path) | share < 1.5× baseline, the path stops occurring, the path is no longer eligible, or the current numbers fall below the fire floors |
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one). Rule-shaped `legacy:*` paths count into BOTH sums (window and baseline): they were the same backend decisions before the 2026-08-23 instrumentation, and without that continuity the group had a near-zero baseline by construction and fired a lift-124 rollout artifact | share < 1.5× baseline or hits stop |
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts (stamped rule paths plus rule-shaped `legacy:*`, same continuity), ≥ 20 verdicts | < 15 % or < 20 verdicts |
Episodes are tracker rows `verdictcalibration|{kind}|{path}|{status}` (register = `AddEntity`,
409 = already burning, fail-closed; refresh carries `FirstNotifiedAt`; 30-day tracker
@@ -140,6 +143,15 @@ Kill switch `VerdictCalibrationRadarDisabled`.
re-enrollment within 7 days after Succeeded 8.9 % (lab background), after Failed 24.3 %,
after Incomplete 10.6 % — Incomplete behaves like Succeeded, so rules 5/6 are probably too
cautious; the matrix now shows this per rule instead of per status.
* **First real calibration read (2026-08-27, four days instrumented):** the per-rule and
per-tenant split resolved that hand analysis — `legacy:r5_incomplete` re-enrolled at 2.6 %
(the `agent:complete_soft` background), spread across tenants, while `legacy:r6`'s 29 %
was concentrated in one tenant that disciplinedly redoes failed enrollments (48.5 % there,
6.7 % elsewhere — so r6 stays untouched, it correctly flags that tenant). Consequences
shipped the same day: rule 5a "completed (assumed)" (see
[Silent-Session Classification](silent-session-classification.md)), the max-lifetime grace
skip, and the radar floors + legacy group continuity above — the first production month
had produced nine standing alerts, every one an instrumentation-rollout artifact.
* **Correction stream:** a session swept to `sweep:r5_incomplete`, later completed by the
agent → row `agent:complete` with `PriorVerdictPath = sweep:r5_incomplete`; the aggregate
increments `OverriddenByLateCompletion` on the `sweep:r5_incomplete / Incomplete` bucket.
@@ -0,0 +1,160 @@
---
type: Concept
title: Vulnerability Correlation Sources (NVD, KEV, MSRC, EPSS)
description: How the software inventory is turned into CVE findings - CPE mapping, the per-CPE NVD cache with its budget and TTLs, KEV/MSRC/EPSS enrichment, the throttle contract that keeps a refused NVD answer out of the cache, and the act/attend/track priority.
resource: src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs
tags:
- backend
- vulnerability
- nvd
- epss
- kev
- msrc
- cache
timestamp: 2026-08-29T18:00:00+02:00
---
# Vulnerability Correlation Sources
The Software Inventory & Vulnerability Analyzer (tenant opt-in) ships the installed-software
list with the session; the backend turns it into CVE findings entirely from cached public
reference data. Four sources, all trustworthy primaries, no aggregators:
| Source | What it contributes | Fetch shape | Cache |
| --- | --- | --- | --- |
| NVD 2.0 (NIST) | CVE records per product: CVSS v3.1 (v3.0 fallback) score, severity, **vector**, description, affected version ranges; CPE dictionary for auto-mapping | `cves/2.0?virtualMatchString={cpe}` per product, `cpes/2.0?keywordSearch=` for unmapped titles; optional API key (`AdminConfiguration.NvdApiKey`) | Azure Table `VulnerabilityCache`, one row per CPE (`cve_{cpe}` / `cache_result`), `CveDataJson` chunked; soft TTL 7 d (serve + background refresh), hard 14 d |
| CISA KEV | "Exploited in the wild" flag, due date, required action, ransomware use | Whole catalog JSON | In-memory dictionary, 24 h, blob mirror `kev-catalog.json` |
| MSRC | Microsoft-side confirmation: document id/title, release date, Microsoft severity / score / vector | CVRF v3.0 index of the last six Patch Tuesdays | In-memory index, blob mirror `msrc-index.json` |
| FIRST EPSS | Probability (0..1) that the CVE is exploited within 30 days + percentile + scoring date | `api.first.org/data/v1/epss?cve=a,b,c` - 100 ids per request, unauthenticated | Stored **on the cached CVE** (no separate table): filled when a CPE row is (re)fetched, re-scored for every row by the nightly sync |
Nothing about a tenant leaves the backend. The one outbound query that carries content is the
EPSS lookup, and its payload is the list of public CVE ids held in the shared, tenant-agnostic
CPE cache. Customer-facing wording: `autopilotmonitor-docs/trust/data-flows.md` and the
terms page attribution block.
# Schema
## Cached CVE (`CachedCve`, serialised into `CveDataJson`)
```
CveId, CvssScore, CvssSeverity, CvssVector?, Description (<=500), PublishedDate,
EpssScore?, EpssPercentile?, EpssDate?, AffectedVersions[] {Criteria, VersionStart/End Incl/Excl}
```
`JsonOptions` is `WhenWritingNull` + case-insensitive, so rows written before the vector / EPSS
properties existed deserialise with those as `null`, and a row is never rewritten just to add
nulls. `CveCacheBudget` bounds the row (24 months, or CVSS >= 7 within 60 months; max 100 CVEs
per CPE, 50 ranges per CVE) - which is also why one CPE fill is exactly one EPSS request.
`ProjectNvdResponse` is the single NVD -> `CachedCve` projection; the live fill and the nightly
refresh both call it, so both write the same shape. `BuildCveCacheRow(cpe, cves, cachedAt)` is the
single row builder; `cachedAt` is the **NVD** freshness stamp, an EPSS-only rewrite passes the
existing value through so it does not look like a fresh NVD fetch to the 7-day TTL.
## Per-finding output (report `findings[].vulnerabilities[]`)
```
cveId, cvssScore, cvssSeverity, cvssVector?, description, publishedDate, source ("NVD" | "NVD+MSRC"),
isKev, kevDueDate, kevRequiredAction, knownRansomware,
epssScore?, epssPercentile?, epssDate?,
priority ("act" | "attend" | "track"),
msrc* (when MSRC confirmed), versionMatchConfidence ("low" on wildcard CPE matches)
```
Finding level: `riskLevel` (unchanged CVSS+KEV bands), `priority` (highest band), `maxEpssScore?`.
`scan_summary` additionally carries `act_cves` / `attend_cves`. `MergeReports` (Part 1 + Part 2)
recomputes all of these. The `CveIndex` row (one per tenant/CVE/session) carries `EpssScore`
(max over occurrences, absent when unscored) and `Priority` (highest), which the fleet summary
(`TopCve.epssScore` / `priority`) reads.
## Priority (`CvePriority.Compute`)
* `act` - in CISA KEV.
* `attend` - EPSS >= 0.1 **or** CVSS >= 9.0.
* `track` - everything else.
Three visible rules instead of a weighted composite: severity (`riskLevel`) keeps saying how bad,
priority says how soon. A CVE without an EPSS score is "unknown likelihood" - it can still reach
`attend` via CVSS, never via a defaulted 0. The thresholds are constants; changing them is a
product decision, not a tuning knob.
# Throttle contract (NVD)
The typed `HttpClient` runs under the `ExternalDataApi` Polly policy: 429/5xx/timeouts are retried
three times (1/2/4 s + jitter) with a circuit breaker. A 429 that reaches `NvdApiClient` therefore
means NVD is refusing us for real. Two things then happen, and one deliberately does not:
1. `NvdFetchResult<T>` reports `Succeeded = false`, `Throttled = true`. `Data` is an empty object
so enumerating callers keep working, but **nothing that persists may treat it as an answer**:
the CPE cache fill returns null and writes no row; the negative auto-resolve cache is not
touched; the admin auto-resolve reports `nvd_throttled`. Before this contract an empty response
was cached as "0 CVEs" for seven days - a product NVD happened to throttle disappeared from
every report for a week.
2. A process-wide cooldown opens: `Retry-After` when present (delta or date, capped at 120 s),
otherwise 30 s. During the cooldown every NVD call fails fast with `Throttled` - no wait, no
retry chain. Worst case added latency per cooldown window is one Polly chain (~7.5 s), never
a per-item stall; the nightly refresh breaks out of its loop on the first throttled answer and
reports the remainder as `cveSkippedThrottled`.
3. Not done on purpose: no in-process retry beyond Polly, no VulnCheck-style mirror, no separate
"throttled" cache state. Hard-expired cache rows keep serving while NVD is unavailable
(better than nothing, logged); a product with no row at all is reported without CVE data for
that run and gets a real attempt on the next correlation.
# Nightly sync (`VulnerabilityDataSync`, 03:00 UTC) and the manual buttons
1. KEV catalog refresh (blob mirror updated).
2. MSRC index refresh (last six Patch Tuesdays).
3. NVD refresh of cache rows older than 7 days (sequential, breaks on throttle).
4. **EPSS re-score of every `cve_*` row** - all ids batched through FIRST, only rows whose score /
percentile / date changed are rewritten (with their original `CachedAt`). A missing id in the
response keeps yesterday's score: a failed batch never erases data.
Steps 3 and 4 live in `VulnerabilityCacheRefreshService` (singleton, one in-flight slot per walk),
which the timer and the Global-Admin buttons share, so a manual run writes exactly what the nightly
run writes. Four endpoints, one per source, each with its own "Last … Sync" line on the admin page
(`GET vulnerability/sync-status`):
| Button | Endpoint | Shape | Timestamp |
| --- | --- | --- | --- |
| Sync KEV | `POST vulnerability/sync` | synchronous | `VulnerabilityDataLastSyncUtc` |
| Sync MSRC | `POST vulnerability/sync-msrc` | synchronous | `MsrcLastSyncUtc` |
| Refresh NVD Cache | `POST vulnerability/sync-nvd` | **202 + background** (minutes, NVD-rate-limited); 409 while running; the page polls `nvdRefreshRunning` / `nvdLastRun` every 10 s | `NvdCacheLastRefreshUtc` - stamped only for a walk that reached every stale row (no throttle cut-off) |
| Sync EPSS | `POST vulnerability/sync-epss` | synchronous (seconds); 409 while running | `EpssLastSyncUtc` |
The timer skips a walk whose slot a manual run currently holds (`cveSkippedBusy` in telemetry)
rather than queueing a duplicate. `nvdLastRun` / `epssLastRun` are in-memory on the answering
instance (null after a cold start); the persisted timestamps are the durable signal.
Telemetry event `VulnerabilityDataSyncCompleted` carries `epssRows / epssCves / epssScored /
epssRowsRewritten / epssError` next to the KEV / MSRC / CVE counters; the manual EPSS run emits
`ManualEpssSyncCompleted`.
# Examples
* Chrome 126 finding: `CVE-2024-4947` CVSS 8.8 HIGH, `AV:N/AC:L/PR:N/UI:R`, KEV yes -> `priority =
act`, riskLevel `critical` (KEV) - unchanged severity semantics, priority now explicit.
* A 5.5 MEDIUM local-privilege CVE with EPSS 0.31 -> `attend`; the same CVE with EPSS 0.004 ->
`track`. Same severity band, different order of work.
* Nightly sync while NVD throttles: `[VulnSync] NVD throttled - leaving 37 stale entries for the
next run`, then `[VulnSync] EPSS refresh: 1412/1450 CVEs scored across 212 rows, 180 rows
rewritten` - EPSS is independent of NVD's mood.
# Not done (recorded)
* **ExploitDB CSV** as a "public exploit exists" flag (static file, daily, same shape as the KEV
mirror) - useful, cheap, deliberately not added: four primary sources are enough for now and
each additional outbound dependency is a trust-page line. Revisit if customers ask "is there a
public exploit?".
* GitHub / Nuclei PoC search, composite 0-100 risk scores, CWE -> ATT&CK mapping - noise for an
Intune admin, not adopted.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/NvdApiClient.cs` - `NvdFetchResult`, cooldown, `ComputeCooldown`
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/EpssApiClient.cs` - batching, `ParseResponse`
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/CvePriority.cs`
* `src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs` - `ProjectNvdResponse`, `ApplyEpssScores`, `BuildCveCacheRow`, `StampFindingPriority`, `FetchAndCacheCvesFromNvdAsync`
* `src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs` - `RefreshStaleCveCacheAsync`, `RefreshEpssScoresAsync`
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` - `BuildCveIndexEntities`, `ScanCveIndexAsync`
* `src/Backend/AutopilotMonitor.Functions.Tests/EpssAndNvdThrottleTests.cs`
* FIRST EPSS API: https://api.first.org/epss/ - CISA KEV: https://www.cisa.gov/known-exploited-vulnerabilities-catalog
+4
View File
@@ -68,6 +68,7 @@ https://docs.autopilotmonitor.com (separate repository).
# MCP Server
* [MCP Protocol Eras & Client ID Metadata Documents](mcp/protocol-eras-and-cimd.md) - One per-request factory serving the 2026-07-28 stateless revision (server/discover, ttlMs/cacheScope, Mcp-Method routing) and 2025-era initialize clients side by side, why the legacy leg stays a hand-wired JSON+gzip transport, the never-public cache-hint contract, and the CIMD client-registration path (SSRF-gated document fetch, same redirect allowlist) next to the deprecated dynamic registration.
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
@@ -78,7 +79,10 @@ https://docs.autopilotmonitor.com (separate repository).
# Security & Identity
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
* [Vulnerability Correlation Sources](backend/vulnerability-correlation-sources.md) - How the software inventory becomes CVE findings from four cached primaries (NVD per-CPE cache with budget and TTLs, CISA KEV, MSRC, FIRST EPSS stored on the cached CVE and re-scored nightly), the NVD throttle contract (a refused answer is never cached as "0 CVEs"; bounded process-wide cooldown honouring Retry-After), the persisted CVSS vector, and the act/attend/track priority that keeps severity and likelihood as two visible signals instead of one composite score.
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry.
* [Admin Identity Binding](backend/admin-identity-binding.md) - Why a GlobalAdmins / DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant (domain re-registration, UPN recycling), the AdminIdentityBindings table pinning each cross-tenant-role UPN to its immutable home tid + oid, the fail-closed resolution order in both role services (row → binding → entitlement), the first-sign-in object-id pin with its race semantics, the 409 on grant-time conflicts, and the explicit GET/PUT/DELETE rebind surface.
* [IME Installer Archive](backend/ime-msi-archive.md) - How every fleet-observed IME build is archived as its MSI: the ime_agent_version sighting that queues the job (first sighting, or a later sighting with a version-authoritative CSP URL after a 24 h backoff), the primary/secondary/hotfix candidate walk, the managed MSI ProductVersion check that keeps another ring's build out of the version folder (Failed:VersionMismatch), provenance with the full candidate walk, and the skill-side overwrite path for corrections.
# Conventions for this bundle
+39
View File
@@ -1,5 +1,44 @@
# Log
## 2026-08-29 (6)
* **Update**: `backend/admin-identity-binding.md` — grants are self-maintaining: `AdminIdentityResolver` derives the home tenant + object id from sign-in history (`UserActivity`), falls back to the UPN domain of an onboarded tenant, refuses to guess when a UPN was seen under two tenants; 422 `HomeTenantUnresolved` + portal tenant picker only for a never-seen domain. Identity fields and pills removed from the delegated / tenant-group forms; inspect/correct lives in Tenant Management → Edit Tenant Configuration → Identity bindings (collapsed).
## 2026-08-29 (5)
* **Creation**: Added `backend/admin-identity-binding.md` — cross-tenant roles (GlobalAdmin / GlobalReader, delegated MSP) were resolved from the UPN string alone while the API accepts tokens from every Entra tenant. New `AdminIdentityBindings` table (RK = UPN → home `TenantId` + `ObjectId`), `AdminIdentity` (upn + tid + oid) as the key both role services resolve on, binding checked after the row and before the Pro gate, object id pinned on the first sign-in from the bound tenant (ETag-guarded), grants require `homeTenantId` and refuse to re-home (409), `global/identity-bindings` GET/PUT/DELETE as the audited rebind path, `RequestContext.ObjectId`, throttle bucket keyed on oid. Legacy rows are inert until bound — seed bindings before the backend deploy.
## 2026-08-29 (4)
* **Creation**: Added `backend/ime-msi-archive.md` — the version-verified, multi-host IME installer archive: MsiProductVersionReader (managed CFB/MSI Property reader), candidate host walk, Failed:VersionMismatch, sighting re-queue with 24 h backoff, and the 2026-08-29 1.105.103.0 poisoning that motivated it.
## 2026-08-29 (3)
* **Update**: `backend/vulnerability-correlation-sources.md` - the admin page's Data Sync Status is complete: next to Sync KEV / Sync MSRC there are now Refresh NVD Cache and Sync EPSS, each with its own last-sync line (`NvdCacheLastRefreshUtc`, `EpssLastSyncUtc` on AdminConfiguration; shared manifest regenerated). The NVD and EPSS walks moved out of the timer into `VulnerabilityCacheRefreshService` (singleton, one in-flight slot per walk) so the button and the nightly run are literally the same code; the NVD walk is minutes long and rate-limited, so `POST vulnerability/sync-nvd` answers 202 and the page polls `sync-status` (`nvdRefreshRunning`, `nvdLastRun`) while it runs, `sync-epss` runs synchronously. 409 while a walk is in flight; the timer skips a slot a manual run holds. Operator-only surface - no customer docs.
## 2026-08-29 (2)
* **New**: `backend/vulnerability-correlation-sources.md` - FIRST EPSS joins NVD / CISA KEV / MSRC as the fourth vulnerability source: the probability + percentile + date live on the cached CVE (one batch of <= 100 ids per CPE fill, every `cve_*` row re-scored by the nightly sync, only changed rows rewritten and with their original `CachedAt`), the CVSS vector is persisted instead of dropped, and each CVE carries an act / attend / track priority (KEV; EPSS >= 0.1 or CVSS >= 9.0; rest) next to the unchanged severity bands - two visible signals, no composite score. The comparison with an open-source CVE MCP server surfaced the real 429 defect: Polly already retried, but a refused NVD answer came back as an empty response and was cached as "0 CVEs" for seven days. `NvdFetchResult` now separates "answered" from "refused" (nothing persists a refusal - CPE cache, negative auto-resolve cache, admin auto-resolve), and a 429 opens a process-wide cooldown (`Retry-After` capped at 120 s, else 30 s) so the run fails fast instead of paying a retry chain per software item; hard-expired rows keep serving meanwhile. NVD -> cache projection and the row builder are now single functions shared by the live fill and the sync. CveIndex / fleet summary carry `epssScore` + `priority`; portal shows EPSS pill, priority pill, vector; MCP tool descriptions and the CVE-audit prompt name the fields. ExploitDB recorded as a deliberate not-now. Backend before web; trust pages (terms attribution, privacy, data-flows), settings and portal-guide docs updated in the same change.
## 2026-08-29
* **New**: `mcp/protocol-eras-and-cimd.md` - the MCP server moved to the 2026-07-28 protocol revision (TypeScript SDK v2: `@modelcontextprotocol/server` + `/node`, zod 4; the monolithic `@modelcontextprotocol/sdk` is gone). One per-request factory (`mcp-server-factory.ts`) feeds both eras through `mcp-http.ts`: 2026 clients are served by the SDK entry (`createMcpHandler`, `legacy: 'reject'`, JSON response mode), 2025 clients keep the existing stateless transport with `enableJsonResponse` - routed on the SDK's own `isLegacyRequest` predicate, because the built-in stateless fallback would answer today's Claude/VS Code clients with SSE and lose the gzip win. Capabilities now declare `listChanged: false` (the SDK default advertised a notification a per-request server cannot send); `cacheHints` stamp `ttlMs`/`cacheScope: 'private'` on discover/list results (5 min) and resource reads (60 min) - never public, the catalog is role-dependent. Client registration: `client_id_metadata_document_supported: true`; an https `client_id` is a Client ID Metadata Document resolved by `cimd.ts` (SSRF gate on the resolved addresses before any request, no redirects, 5 s / 16 KB, exact `client_id` match, bounded `redirect_uris`, 10-60 min cache), the requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR client_ids stay as the fallback. `mcp-oauth-flow.md` updated. Package version 1.5 -> 1.6. Pinned by `mcp-http.test.ts` (real v2 client over loopback HTTP, both eras, role parity), `cimd.test.ts`, `oauth-cimd.test.ts`.
## 2026-08-28
* **Update**: `CmTraceSkewTripwire` - the backend regression tripwire additionally requires >= 80 % of the individual IME deltas (relative to the other side's median) to sit within 2 min of some 15-minute grid multiple before `CmTraceTimeSkewRegression` fires; the ops event and its details now carry that fraction. Motivation: the first soak week produced 7 fires, all backlog replays - a relaunched agent re-tailing the IME log uploads one burst holding most of the session's IME samples with a continuum of ages whose median hit the grid by chance (session d832560a: 508 of 627 samples, 46...733 min, median 91). Era-mixing regressions (every line on the grid) still fire. Doc: `docs/agent/cmtrace-time-resolution.md`.
## 2026-08-27 (2)
* **Update**: `SecurityValidator` / `DeviceAssociationValidator` - Microsoft released Windows Autopilot device association on 2026-08-25 (learn.microsoft.com/autopilot/device-preparation/device-association/overview: TPM-backed UEFI marker, device-targeted device preparation policies, associated devices are marked corporate-owned by Intune with no corporate identifier upload). The DevPrep association lookup therefore leaves shadow mode and becomes the third accepting method of the device-validation gate, evaluated inline after the Autopilot and Corporate Identifier lookups and before the Cloud PC fallback; a match sets `ValidatedBy = DeviceAssociation`, a miss joins the combined 403 detail, a transient failure the 503 Retry-After - the same contract as its siblings, so enabling it can only widen acceptance, never reject a device the other methods would have taken. `ValidateDeviceAssociation` counts toward "at least one validation enabled" and is no longer GA-only anywhere (`UpdateTenantConfigurationFunction` revert, `TenantConfigPatchService.GaOnlyFields`, the Global-Admin gate on the settings toggle); the toggle sits under Device Preparation next to Corporate Identifier and follows the shared consent rule (first serial-based validation carries the consent, any further one is a plain persisted toggle). Known gap carried over from the shadow phase: the `/beta/deviceManagement/tenantAssociatedDevices` endpoint is still undocumented in Graph reference and every production lookup so far answered `matched=False` for Autopilot v1 devices, i.e. the positive match path is unproven in the field - first associated enrollment must be checked for `ValidatedBy = DeviceAssociation` on the session row. Landing hero carries a release pill linking to the customer docs; dashboard tip added. Backend before web; customer docs updated in `autopilotmonitor-docs`.
## 2026-08-27
* **Update**: `backend/silent-session-classification.md`, `backend/verdict-calibration.md` - the first real calibration read tuned what it measures. Rule 5 grew a past-grace fork 5a: Device Setup done + real-user desktop + zero `app_install_failed` reconciles to Succeeded "completed (assumed)" (`r5_assumed`) - r5-Incomplete devices re-enrolled at 2.6 %, the succeeded background, and the sampled desktop half had clean app records; no desktop or any failed app stays Incomplete. The max-lifetime watchdog (either event shape) now skips the AwaitingUser grace entirely - all five observed maxlife parks expired unhealed, and late telemetry heals a terminal verdict anyway - while WhiteGlove rule 1b keeps its park (its one episode healed). The drift radar got floors (≥ 10 window hits; per-path share regression needs ≥ 5 baseline hits, so new/renamed vocabulary cannot "regress") and legacy continuity: rule-shaped `legacy:*` paths count into the silence and classifier-verdict group sums on both sides of the 2026-08-23 cutover, which dissolves the lift-124 silence artifact and lets sub-floor episodes clear themselves. Backend only; the new path shows up in the matrix on its own.
* **Update**: `backend/tenant-activation-welcome-mail.md` - the reverse lookup. A delivery log names only the recipient, so an address had to lead back to its tenant: the tenant console's search now matches the notification and contact address as well as domain and tenant id, with the quoted exact mode unchanged. The addresses sit in `PreviewWhitelist`, not on the tenant config, so a new GA/Reader-only `GET /api/preview/notification-emails` returns them as one map (single cross-partition scan of `RowKey eq 'notification-email'`) instead of a point read per tenant; both sides join on the lowercased tenant id because the address rows carry the JWT `tid` while a config PartitionKey's casing is not guaranteed, and matching is case-insensitive because stored addresses keep the casing the user typed. The map is best-effort in the portal - a failed read costs only the search-by-address, never the tenant list - and the addresses stay out of the list rows (search key, not a column). Match rules moved into `tenantSearch.ts` with unit pins. Backend before web.
## 2026-08-26
* **Update**: `agent/wifi-ssid-location-gate.md` - the network timeline now explains its blank signal too, with no agent change: `buildSegments` already merges `wifi_signal_info` into the covering WiFi segment, so `wifiDataLimitedReason` rides in with the sample. Segments that never got a sample (a mid-session reconnect emits none) inherit it - the gate is a device-level setting and cannot flip mid-enrollment - while a segment with a real reading is never overwritten. Tooltip shows `Signal: unavailable - Location services off on this device`; the "Avg WiFi signal" stat reads `n/a (Location services off)` instead of vanishing. `network_state_change` is deliberately left alone: it has never carried `signalPercent` or `radioType` (only `before_/after_wifiSsid` reaches its payload), so there is no absent field there for a reason to explain, and the SSID it does carry is unaffected by the gate. Web-only change.
+11 -7
View File
@@ -8,7 +8,7 @@ tags:
- oauth
- entra-id
- security
timestamp: 2026-07-11T10:59:31+02:00
timestamp: 2026-08-29T08:45:00+02:00
---
# MCP OAuth Flow — Who Authenticates Where
@@ -41,9 +41,12 @@ The two identities never mix:
in the vendor's backend, not on the user's machine.
2. **MCP server** (Container App) — acts as a stateless **OAuth proxy /
authorization-server façade** in front of Entra ID. It is *not* an IdP:
it registers clients dynamically (RFC 7591, HMAC-signed stateless
`client_id`), enforces the redirect-URI allowlist and PKCE, and exchanges
codes at Entra using its confidential client secret.
it establishes the client's identity statelessly — a Client ID Metadata
Document (MCP spec 2026-07-28: the `client_id` is an HTTPS URL the server
fetches and validates) or, for older clients, dynamic registration
(RFC 7591, HMAC-signed stateless `client_id`) — enforces the redirect-URI
allowlist and PKCE, and exchanges codes at Entra using its confidential
client secret. See [Protocol Eras & CIMD](mcp/protocol-eras-and-cimd.md).
3. **Entra ID** — the real identity provider. This is where `adm.luke` lives.
## Full sequence
@@ -54,9 +57,10 @@ Claude Desktop Browser (user's MCP server Entra ID
│ │ │ │
1. │─ POST /mcp ──────────┼───────────────────────▶│ 401 + WWW-Auth │
2. │─ Discovery ──────────┼───────────────────────▶│ RFC 9728/8414 docs │
3. │─ POST /oauth/register┼───────────────────────▶│ client_id (HMAC- │
│ │ │ signed, embeds the │
│ │ │ registered callback) │
3. │─ client identity ────┼───────────────────────▶│ CIMD: client_id is a │
│ (CIMD, or legacy │ │ URL, doc fetched at │
│ POST /oauth/ │ │ /authorize; DCR: │
│ register) │ │ HMAC-signed client_id│
│ │ │ │
4. │─ opens browser ─────▶│─ GET /oauth/authorize ▶│─ 302 to Entra ──────▶│
│ │ (PKCE challenge) │ app-reg client_id, │
+129
View File
@@ -0,0 +1,129 @@
---
type: Concept
title: MCP Protocol Eras & Client ID Metadata Documents
description: How the MCP server serves the 2026-07-28 stateless revision and 2025-era clients from one per-request factory, why the legacy leg is hand-wired (JSON + gzip), the cache-hint and capability contract, and the CIMD client-registration path next to the deprecated dynamic registration.
resource: /src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts
tags:
- mcp
- protocol
- oauth
- cimd
- security
timestamp: 2026-08-29T08:40:00+02:00
---
# MCP Protocol Eras & Client ID Metadata Documents
**Status:** Concept · **Owner:** MCP Server · **Code:** [`mcp-http.ts`](../../src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts), [`mcp-server-factory.ts`](../../src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts), [`cimd.ts`](../../src/McpServer/autopilot-monitor-mcp/src/cimd.ts), [`oauth.ts`](../../src/McpServer/autopilot-monitor-mcp/src/oauth.ts)
# Schema
## The two eras
MCP revision **2026-07-28** turned the protocol into a stateless request/response
protocol: no `initialize` handshake, no `Mcp-Session-Id`, every request carries its
protocol version, client identity and capabilities in `_meta`, capabilities and
instructions are fetched with `server/discover`, list results carry `ttlMs` /
`cacheScope`, and `Mcp-Method` / `Mcp-Name` HTTP headers let gateways route without
parsing bodies. Roots, Sampling, Logging and the HTTP+SSE transport are deprecated
(twelve-month window); Dynamic Client Registration is deprecated in favour of Client
ID Metadata Documents.
The server was already stateless by design (scale-to-zero Container App, no
server→client traffic), so the revision cost no architecture — only wiring:
| Concern | 2026-07-28 ("modern") | 2025-era ("legacy") |
| --- | --- | --- |
| Routing decision | `isLegacyRequest()` from the SDK — the exact classifier `createMcpHandler` uses internally | same predicate, inverse |
| Serving | `createMcpHandler(factory, { legacy: 'reject', responseMode: 'json' })` via `toNodeHandler` | fresh `NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })` per POST |
| Response shape | single `application/json` body | single `application/json` body |
| Instructions | `server/discover` result | `initialize` result |
| Cache fields | `ttlMs` + `cacheScope` stamped by the SDK from `cacheHints` | none (2025 codec has no cache path) |
Both legs are fed by **one** factory (`createServerForCaller`), so the tool catalog,
resource set, prompts and instructions can never drift between eras — the
`mcp-http.test.ts` suite pins that a Global Admin and a plain tenant user see the
same catalog on either path.
## Why the legacy leg is hand-wired
`createMcpHandler`'s built-in `legacy: 'stateless'` fallback constructs its transport
with only `sessionIdGenerator: undefined`, i.e. it answers every 2025 client with an
SSE frame. Every production client today (Claude Code, Claude.ai, VS Code) is a 2025
client, and their large tool results are gzipped by the compression middleware only
because the response is a plain JSON body (measured 5–30×). Routing on the SDK's own
predicate in user land keeps that behaviour while still running the 2026 path through
the SDK entry unchanged — no re-implementation of the classification, no second
catalog.
## Capability and cache contract
* `capabilities` are declared explicitly with `listChanged: false` for tools,
resources and prompts. The SDK's default (`?? true`) would advertise a
notification a per-request server can never emit.
* `cacheHints`: `server/discover`, `tools/list`, `prompts/list`, `resources/list`,
`resources/templates/list` → `{ ttlMs: 300_000, cacheScope: 'private' }`;
`resources/read` → `{ ttlMs: 3_600_000, cacheScope: 'private' }`. **Never
`public`**: the catalog and the instructions are role-dependent (Global Admin /
Global Reader / delegated MSP / tenant user), so a shared cache would leak one
caller's surface to another. Five minutes is the bound on how long a role change
can go unnoticed by a caching client; resources are static per deployment.
* No Roots / Sampling / Logging / elicitation / subscriptions are used anywhere, so
nothing on the deprecation list has to be migrated.
## Client registration: CIMD first, DCR as fallback
The authorization-server metadata now advertises
`client_id_metadata_document_supported: true` next to the (deprecated)
`registration_endpoint`. Clients following the spec's priority order use a
**Client ID Metadata Document**: their `client_id` *is* an HTTPS URL that serves a
JSON document (`client_id`, `client_name`, `redirect_uris`, …). `oauth.ts` resolves
every `client_id` through one helper: an HTTPS URL goes to `cimd.ts`, anything else
must be one of our HMAC-signed dynamic-registration tokens. Both yield the same shape
(`redirectUris`), and the rest of the authorize/callback logic is unchanged.
What the document decides — and what it does not:
* It only asserts **which** redirect URIs the client claims. The requested
`redirect_uri` must still pass the host/path allowlist that gates dynamic
registration (`isAllowedRedirectUri`), checked *before* the document is fetched. A
self-hosted document cannot widen the destinations an authorization code may be
sent to. Loopback remains allowed for every client (RFC 8252 §7.3);
`application_type: "native"` is informational.
* Fetching a caller-chosen URL is an SSRF surface, so the fetch is: HTTPS only, path
component required, no fragment/userinfo, no loopback or IP-literal host, every
resolved address must be public (RFC 1918/4193/3927/6598, CGNAT, multicast,
IPv4-mapped all refused — checked before any request leaves), `redirect: 'error'`,
5 s budget, 16 KB body cap (declared *and* streamed), JSON media type required.
Nothing from the response reaches the caller beyond an OAuth error code.
* The document's `client_id` must equal the URL by simple string comparison — no
normalization, per the draft. `redirect_uris` is bounded exactly like a dynamic
registration (`oauth-limits.ts`). A missing `client_name` falls back to the host:
it only ever reaches a log line, and failing a login over a label would be wrong.
* Cache: positive entries live for the document's `Cache-Control: max-age`, clamped
to 10–60 min so the `/oauth/callback` re-check within the 10-min signed-state
window is a cache hit on the same replica; rejections are cached for 60 s; the map
is bounded to 256 entries.
RFC 9207 `iss` (already emitted since 2026-07-29) completes the authorization side
of the 2026-07-28 revision.
# Examples
* A 2026 client: `POST /mcp` with `_meta["io.modelcontextprotocol/protocolVersion"] = "2026-07-28"`
and `Mcp-Method: tools/list` → `{ tools: [...], ttlMs: 300000, cacheScope: "private", _meta: { "io.modelcontextprotocol/serverInfo": { name: "Autopilot-Monitor", version: "1.6.<build>" } } }`.
* A 2025 client: `POST /mcp` `initialize` → `application/json` body with
`protocolVersion: "2025-11-25"` and the role-tailored `instructions`; no
`Mcp-Session-Id` header.
* `GET /oauth/authorize?client_id=https://app.example.test/oauth/client.json&redirect_uri=http://127.0.0.1:49152/callback&code_challenge=…` →
document fetched (or served from cache) → 302 to Entra. `client_id=https://10.0.0.8/x` → `400 invalid_client`
without any outbound request.
# Citations
* MCP spec 2026-07-28 — https://blog.modelcontextprotocol.io/posts/2026-07-28/ ; authorization / client registration:
https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration
* TypeScript SDK v2 (`@modelcontextprotocol/server` 2.0.0) — `createMcpHandler`, `isLegacyRequest`, `cacheHints`.
* draft-ietf-oauth-client-id-metadata-document-00 §6 (security considerations, SSRF).
* Tests: `src/__tests__/mcp-http.test.ts`, `src/__tests__/cimd.test.ts`, `src/__tests__/oauth-cimd.test.ts`.
* Related: [MCP OAuth Flow](../mcp-oauth-flow.md), [MCP Docs Corpus](docs-corpus.md).
+92
View File
@@ -0,0 +1,92 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-APP-017",
"title": "App Install Blocked by Another Installer (0x80070652)",
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
"severity": "high",
"category": "apps",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 70,
"conditions": [
{
"signal": "mutex_blocked_install",
"source": "event_type",
"eventType": "app_install_failed",
"dataField": "hresultFromWin32",
"operator": "equals",
"value": "-2147023278",
"required": true
},
{
"signal": "installer_exit_1618",
"source": "event_data",
"eventType": "app_install_failed",
"dataField": "exitCode",
"operator": "equals",
"value": "1618",
"required": false
},
{
"signal": "esp_failed_with_mutex_code",
"source": "event_data",
"eventType": "esp_failure_settle_started",
"dataField": "errorCode",
"operator": "equals",
"value": "0x80070652",
"required": false
}
],
"confidenceFactors": [
{
"signal": "installer_exit_1618",
"condition": "exists",
"weight": 10
},
{
"signal": "esp_failed_with_mutex_code",
"condition": "exists",
"weight": 20
}
],
"confidenceThreshold": 65,
"explanation": "App **{{appName}}** failed to install because the Windows Installer service was already busy with another installation — HRESULT **0x80070652** / MSI exit code **1618** (`ERROR_INSTALL_ALREADY_RUNNING`). Only one Windows Installer transaction can run at a time; the Intune Management Extension retried on its fixed budget (typically ~15-17 minutes across several attempts) and then reported the app as failed. If this app is required in the Enrollment Status Page, the Device Setup Apps subcategory fails with it and the enrollment terminates.\n\nThis failure is **deterministic per attempt**: as long as the competing installer keeps winning the race, a plain re-enrollment fails again at roughly the same minute mark — retrying without fixing the collision only churns devices.\n\nCommon sources of the competing installation:\n\n- The **preceding Win32 app in the install order**: its installer exits 0 (so IME moves on) but leaves a child `msiexec` transaction or post-install MSI work running. Security agents and proxy clients that chain multiple MSIs are frequent offenders.\n- **OEM factory software** installing on first boot (factory-image provisioning packages, vendor updaters) — typical on brand-new hardware batches.\n- **Microsoft 365 Apps (C2R), driver updaters or Windows servicing** running installations in parallel outside IME's control.",
"remediation": [
{
"title": "Identify the competing installer",
"steps": [
"In this session's timeline, note which app finished installing immediately before {{appName}} started — its installer is the prime suspect for holding the Windows Installer mutex",
"On a reproduction device, when the failure window is active, check for running installers: Get-Process msiexec | Select-Object Id,StartTime and the in-progress marker key HKLM\\SOFTWARE\\Microsoft\\Windows\\Installer\\InProgress",
"Check the Application event log for MsiInstaller events around the failure timestamp to see which product held the transaction",
"On factory-fresh hardware, also look for OEM first-boot installers (vendor updater services, factory provisioning packages) that run outside Intune"
]
},
{
"title": "Serialize the install chain",
"steps": [
"Add an Intune Win32 dependency so {{appName}} depends on the app whose installer causes the collision — IME then orders them explicitly instead of racing",
"Alternatively, wrap the offending predecessor so its install script waits until msiexec has fully finished (poll the InProgress registry key or the _MSIExecute mutex with a bounded timeout) before exiting",
"If the app does not have to gate the ESP, remove it from the blocking-app list so a transient 1618 no longer terminates the enrollment; Intune retries it after the desktop"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: MsiExec.exe error messages (1618 = another installation is in progress)",
"url": "https://learn.microsoft.com/en-us/windows/win32/msi/error-codes"
},
{
"title": "Microsoft Learn: Troubleshoot Win32 app installations in Intune",
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
}
],
"tags": [
"apps",
"msiexec",
"mutex",
"0x80070652",
"1618",
"esp"
]
}
+81
View File
@@ -0,0 +1,81 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-NET-001",
"title": "Diagnostics Upload Blocked by Web Security Gateway",
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
"severity": "warning",
"category": "network",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 75,
"conditions": [
{
"signal": "foreign_html_response",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "contains",
"value": "HTML platform error page",
"required": true
},
{
"signal": "security_gateway_marker",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "regex",
"value": "mcafee|skyhigh|zscaler|netskope|blue ?coat|proxysg|forcepoint|websense|palo ?alto|fortigate|fortiguard|sophos|watchguard|barracuda|iboss|umbrella|lightspeed|smoothwall|contentkeeper|web ?gateway|web ?filter|content ?filter|secure ?gateway",
"required": true
},
{
"signal": "http_403_block",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "contains",
"value": "403",
"required": false
}
],
"confidenceFactors": [
{
"signal": "http_403_block",
"condition": "exists",
"weight": 15
}
],
"confidenceThreshold": 70,
"explanation": "The agent collected a diagnostics package but the upload was answered with an **HTML block page from a web security gateway** instead of a response from the Autopilot Monitor backend — the event message names the product that served the page. A forward proxy / SWG with SSL inspection is intercepting HTTPS traffic from the enrollment network and blocking the upload endpoint.\n\nThe consequence is quietly painful: diagnostics upload is typically configured **OnFailure**, so the sessions that fail — the ones where the IME logs and diagnostics ZIP would explain the root cause — are exactly the sessions whose evidence never arrives. Enrollment telemetry itself may still flow (the agent often starts before the gateway's client enforcement does, or other endpoints are treated differently), which makes the gap easy to miss until a failure needs investigating.\n\nNote this rule only fires when the blocking page carries a recognizable security-product marker. A plain HTML error page without one usually means a stale or stopped endpoint instead — that is a different problem.",
"remediation": [
{
"title": "Exempt the upload endpoint in the web gateway",
"steps": [
"Identify the product from the block-page title quoted in this event's message (e.g. a Skyhigh/McAfee Web Gateway notification page)",
"In that gateway, add the Autopilot Monitor API host shown in the event message to the SSL-inspection bypass AND the URL allowlist for the enrollment network segment",
"Enrollment traffic often originates from a dedicated provisioning VLAN or from devices without the proxy client yet — make sure the exemption applies to that segment, not only to managed clients",
"If the gateway enforces per-user policy, remember ESP-phase traffic runs as SYSTEM/device before any user signs in — the exemption must cover unauthenticated device traffic"
]
},
{
"title": "Verify the fix",
"steps": [
"Re-run an enrollment (or wait for the next failed one) and confirm a diagnostics package appears on the session in the portal",
"Optionally set diagnostics upload to Always temporarily to validate the path without needing a failure"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Windows Autopilot networking requirements",
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
}
],
"tags": [
"network",
"proxy",
"ssl-inspection",
"swg",
"diagnostics"
]
}
+171
View File
@@ -943,6 +943,97 @@
"delivery-gap"
]
},
{
"ruleId": "ANALYZE-APP-017",
"title": "App Install Blocked by Another Installer (0x80070652)",
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
"severity": "high",
"category": "apps",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 70,
"conditions": [
{
"signal": "mutex_blocked_install",
"source": "event_type",
"eventType": "app_install_failed",
"dataField": "hresultFromWin32",
"operator": "equals",
"value": "-2147023278",
"required": true
},
{
"signal": "installer_exit_1618",
"source": "event_data",
"eventType": "app_install_failed",
"dataField": "exitCode",
"operator": "equals",
"value": "1618",
"required": false
},
{
"signal": "esp_failed_with_mutex_code",
"source": "event_data",
"eventType": "esp_failure_settle_started",
"dataField": "errorCode",
"operator": "equals",
"value": "0x80070652",
"required": false
}
],
"confidenceFactors": [
{
"signal": "installer_exit_1618",
"condition": "exists",
"weight": 10
},
{
"signal": "esp_failed_with_mutex_code",
"condition": "exists",
"weight": 20
}
],
"confidenceThreshold": 65,
"explanation": "App **{{appName}}** failed to install because the Windows Installer service was already busy with another installation — HRESULT **0x80070652** / MSI exit code **1618** (`ERROR_INSTALL_ALREADY_RUNNING`). Only one Windows Installer transaction can run at a time; the Intune Management Extension retried on its fixed budget (typically ~15-17 minutes across several attempts) and then reported the app as failed. If this app is required in the Enrollment Status Page, the Device Setup Apps subcategory fails with it and the enrollment terminates.\n\nThis failure is **deterministic per attempt**: as long as the competing installer keeps winning the race, a plain re-enrollment fails again at roughly the same minute mark — retrying without fixing the collision only churns devices.\n\nCommon sources of the competing installation:\n\n- The **preceding Win32 app in the install order**: its installer exits 0 (so IME moves on) but leaves a child `msiexec` transaction or post-install MSI work running. Security agents and proxy clients that chain multiple MSIs are frequent offenders.\n- **OEM factory software** installing on first boot (factory-image provisioning packages, vendor updaters) — typical on brand-new hardware batches.\n- **Microsoft 365 Apps (C2R), driver updaters or Windows servicing** running installations in parallel outside IME's control.",
"remediation": [
{
"title": "Identify the competing installer",
"steps": [
"In this session's timeline, note which app finished installing immediately before {{appName}} started — its installer is the prime suspect for holding the Windows Installer mutex",
"On a reproduction device, when the failure window is active, check for running installers: Get-Process msiexec | Select-Object Id,StartTime and the in-progress marker key HKLM\\SOFTWARE\\Microsoft\\Windows\\Installer\\InProgress",
"Check the Application event log for MsiInstaller events around the failure timestamp to see which product held the transaction",
"On factory-fresh hardware, also look for OEM first-boot installers (vendor updater services, factory provisioning packages) that run outside Intune"
]
},
{
"title": "Serialize the install chain",
"steps": [
"Add an Intune Win32 dependency so {{appName}} depends on the app whose installer causes the collision — IME then orders them explicitly instead of racing",
"Alternatively, wrap the offending predecessor so its install script waits until msiexec has fully finished (poll the InProgress registry key or the _MSIExecute mutex with a bounded timeout) before exiting",
"If the app does not have to gate the ESP, remove it from the blocking-app list so a transient 1618 no longer terminates the enrollment; Intune retries it after the desktop"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: MsiExec.exe error messages (1618 = another installation is in progress)",
"url": "https://learn.microsoft.com/en-us/windows/win32/msi/error-codes"
},
{
"title": "Microsoft Learn: Troubleshoot Win32 app installations in Intune",
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
}
],
"tags": [
"apps",
"msiexec",
"mutex",
"0x80070652",
"1618",
"esp"
]
},
{
"ruleId": "ANALYZE-CORR-003",
"title": "Proxy Configuration Causing Download Failure",
@@ -2964,6 +3055,86 @@
"esp"
]
},
{
"ruleId": "ANALYZE-NET-001",
"title": "Diagnostics Upload Blocked by Web Security Gateway",
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
"severity": "warning",
"category": "network",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 75,
"conditions": [
{
"signal": "foreign_html_response",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "contains",
"value": "HTML platform error page",
"required": true
},
{
"signal": "security_gateway_marker",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "regex",
"value": "mcafee|skyhigh|zscaler|netskope|blue ?coat|proxysg|forcepoint|websense|palo ?alto|fortigate|fortiguard|sophos|watchguard|barracuda|iboss|umbrella|lightspeed|smoothwall|contentkeeper|web ?gateway|web ?filter|content ?filter|secure ?gateway",
"required": true
},
{
"signal": "http_403_block",
"source": "event_data",
"eventType": "diagnostics_upload_failed",
"dataField": "errorCode",
"operator": "contains",
"value": "403",
"required": false
}
],
"confidenceFactors": [
{
"signal": "http_403_block",
"condition": "exists",
"weight": 15
}
],
"confidenceThreshold": 70,
"explanation": "The agent collected a diagnostics package but the upload was answered with an **HTML block page from a web security gateway** instead of a response from the Autopilot Monitor backend — the event message names the product that served the page. A forward proxy / SWG with SSL inspection is intercepting HTTPS traffic from the enrollment network and blocking the upload endpoint.\n\nThe consequence is quietly painful: diagnostics upload is typically configured **OnFailure**, so the sessions that fail — the ones where the IME logs and diagnostics ZIP would explain the root cause — are exactly the sessions whose evidence never arrives. Enrollment telemetry itself may still flow (the agent often starts before the gateway's client enforcement does, or other endpoints are treated differently), which makes the gap easy to miss until a failure needs investigating.\n\nNote this rule only fires when the blocking page carries a recognizable security-product marker. A plain HTML error page without one usually means a stale or stopped endpoint instead — that is a different problem.",
"remediation": [
{
"title": "Exempt the upload endpoint in the web gateway",
"steps": [
"Identify the product from the block-page title quoted in this event's message (e.g. a Skyhigh/McAfee Web Gateway notification page)",
"In that gateway, add the Autopilot Monitor API host shown in the event message to the SSL-inspection bypass AND the URL allowlist for the enrollment network segment",
"Enrollment traffic often originates from a dedicated provisioning VLAN or from devices without the proxy client yet — make sure the exemption applies to that segment, not only to managed clients",
"If the gateway enforces per-user policy, remember ESP-phase traffic runs as SYSTEM/device before any user signs in — the exemption must cover unauthenticated device traffic"
]
},
{
"title": "Verify the fix",
"steps": [
"Re-run an enrollment (or wait for the next failed one) and confirm a diagnostics package appears on the session in the portal",
"Optionally set diagnostics upload to Always temporarily to validate the path without needing a failure"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Windows Autopilot networking requirements",
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
}
],
"tags": [
"network",
"proxy",
"ssl-inspection",
"swg",
"diagnostics"
]
},
{
"ruleId": "ANALYZE-OFFICE-001",
"title": "Microsoft 365 Apps Install Failed",
@@ -527,7 +527,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
// On-demand mid-session (no outcome yet): the OnFailure gate must NOT skip —
// there is no known success to skip on. Reaching the archive build (sentinel
// throw → error result) proves the gate was passed; a skip would return null.
// throw → error result) proves the gate was passed; a skip would return Skipped.
using var rig = new Rig();
var result = await BuildGateProbe(rig.Logger, "OnFailure")
.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: "server-requested");
@@ -543,7 +543,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
var result = await BuildGateProbe(rig.Logger, "OnFailure")
.CreateAndUploadAsync(enrollmentSucceeded: true);
Assert.Null(result);
Assert.NotNull(result);
Assert.True(result!.Skipped);
Assert.False(result.Success);
Assert.Equal(DiagnosticsUploadResult.SkipOnFailureSucceeded, result.ErrorCode);
}
[Fact]
@@ -553,7 +556,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
var result = await BuildGateProbe(rig.Logger, "Off")
.CreateAndUploadAsync(enrollmentSucceeded: null);
Assert.Null(result);
// The gate name travels in ErrorCode so the dispatcher's server_action_failed
// event says "mode_off" instead of a generic "diagnostics_upload_failed".
Assert.NotNull(result);
Assert.True(result!.Skipped);
Assert.Equal(DiagnosticsUploadResult.SkipModeOff, result.ErrorCode);
}
[Theory]
@@ -122,6 +122,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Assert.Equal("config_fetch_failed", (string)typed["failureReason"]);
}
[Fact]
public async Task Dispatch_request_diagnostics_skipped_by_gate_surfaces_gate_name_as_failure_reason()
{
// Session 2550bcea (2026-08-29): the on-demand collection was refused by the
// mode=Off gate and the portal only saw a generic "diagnostics_upload_failed".
// The gate name must travel through to the event so the operator can tell a
// config gate from a real upload error.
using var rig = new Rig();
rig.DiagnosticsResult = DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipModeOff);
await rig.Build().DispatchAsync(new List<ServerAction>
{
new ServerAction { Type = ServerActionTypes.RequestDiagnostics, QueuedAt = DateTime.UtcNow },
});
Assert.Equal(1, rig.DiagnosticsCalls);
Assert.Empty(rig.EventsOfType("server_action_executed"));
var failed = rig.EventsOfType("server_action_failed").Single();
var typed = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(failed.TypedPayload!);
Assert.Equal(DiagnosticsUploadResult.SkipModeOff, (string)typed["failureReason"]);
}
[Fact]
public async Task Dispatch_unknown_action_type_emits_failed_with_unknown_action_type_reason()
{
@@ -45,10 +45,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
public string SasUrlPrefix { get; set; }
/// <summary>Human-readable error code/message when upload failed. Null on success or skip.</summary>
/// <summary>Human-readable error code/message when upload failed or was skipped. Null on success.</summary>
public string ErrorCode { get; set; }
/// <summary>
/// True when no package was built because a configuration gate said so (mode=Off,
/// destination not configured, OnFailure + known success). <see cref="ErrorCode"/>
/// then carries the machine-readable gate name so the emitted
/// <c>server_action_failed</c> / <c>diagnostics_upload_failed</c> event names the
/// actual reason instead of a generic "upload failed".
/// </summary>
public bool Skipped { get; set; }
public bool Success => BlobName != null;
public const string SkipModeOff = "diagnostics_skipped_mode_off";
public const string SkipNotConfigured = "diagnostics_skipped_not_configured";
public const string SkipOnFailureSucceeded = "diagnostics_skipped_onfailure_success";
public static DiagnosticsUploadResult SkippedBy(string gate) =>
new DiagnosticsUploadResult { Skipped = true, ErrorCode = gate };
}
/// <summary>
@@ -191,7 +207,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// <summary>
/// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container.
/// Returns a DiagnosticsUploadResult with BlobName set on success, or ErrorCode set on failure.
/// Returns null if the upload was skipped (mode=Off, not configured, or OnFailure+succeeded).
/// A configuration gate (mode=Off, not configured, OnFailure+succeeded) returns a result
/// with <see cref="DiagnosticsUploadResult.Skipped"/> and the gate name in ErrorCode.
/// This method is non-fatal: all exceptions are caught and logged.
/// </summary>
/// <param name="enrollmentSucceeded">
@@ -214,14 +231,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
{
_logger.Debug("Diagnostics upload disabled (mode=Off)");
return null;
_logger.Info("Diagnostics upload skipped: DiagnosticsUploadMode=Off on the running agent.");
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipModeOff);
}
if (!_configuration.DiagnosticsUploadEnabled)
{
_logger.Debug("Diagnostics upload skipped: not configured for this tenant");
return null;
_logger.Info($"Diagnostics upload skipped: no upload destination configured for this tenant (mode={mode}).");
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipNotConfigured);
}
// Null outcome (on-demand mid-session) passes through: the OnFailure gate only
@@ -229,7 +246,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded == true)
{
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
return null;
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipOnFailureSucceeded);
}
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={(enrollmentSucceeded.HasValue ? enrollmentSucceeded.ToString() : "n/a")}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
@@ -115,10 +115,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// <summary>
/// Applies a live-fetched config to the runtime: merge, tracker ceilings, log level,
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path
/// and the post-registration recovery path so both apply identical semantics.
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path,
/// the post-registration recovery path and the live <c>rotate_config</c> ServerAction
/// (<see cref="ServerControlPlane.BuildDispatcher"/>) so all three apply identical semantics.
/// </summary>
private static RemoteConfigMergeResult ApplyFetchedConfig(
internal static RemoteConfigMergeResult ApplyFetchedConfig(
AgentConfiguration agentConfig,
AgentConfigResponse remoteConfig,
BackendAuthBundle auth,
@@ -420,6 +420,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
terminationHandler: terminationHandler,
remoteConfigService: remoteConfigService,
diagnosticsService: diagnosticsService,
auth: auth,
agentVersion: agentVersion,
consoleMode: consoleMode,
shutdown: shutdown,
shutdownComplete: shutdownComplete,
post: lifecyclePost,
@@ -33,6 +33,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
EnrollmentTerminationHandler terminationHandler,
RemoteConfigService remoteConfigService,
DiagnosticsPackageService diagnosticsService,
BackendAuthBundle auth,
string agentVersion,
bool consoleMode,
ManualResetEventSlim shutdown,
ManualResetEventSlim shutdownComplete,
InformationalEventPost post,
@@ -41,9 +44,31 @@ namespace AutopilotMonitor.Agent.V2.Runtime
return new ServerActionDispatcher(
configuration: agentConfig,
logger: logger,
// rotate_config = fetch AND apply. FetchConfigAsync only stores the response
// in RemoteConfigService.CurrentConfig; the projection onto the live
// AgentConfiguration (DiagnosticsUploadMode, LogLevel, SelfDestruct, ...)
// is ApplyFetchedConfig — the same path the startup fetch uses. Without it
// the portal's "enable diagnostics, then collect" flow fetched the new
// config and still ran the diagnostics gate against the stale mode.
// FetchConfigAsync never throws: a failed fetch falls back to cache/defaults
// and reports it via LastFetchOutcome — that is NOT an executed rotation.
rotateConfigAsync: async () =>
{
try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
try
{
var fetched = await remoteConfigService.FetchConfigAsync();
if (remoteConfigService.LastFetchOutcome != RemoteConfigFetchOutcome.Succeeded)
{
logger.Warning($"ServerAction rotate_config: live fetch failed (outcome={remoteConfigService.LastFetchOutcome}, {remoteConfigService.LastFetchFailureMessage}) — runtime config unchanged.");
return false;
}
var mergeResult = AgentRuntimeConfig.ApplyFetchedConfig(
agentConfig, fetched, auth, agentVersion, consoleMode, logger);
LifecycleEmitters.EmitUnrestrictedModeAuditIfChanged(post, agentConfig, mergeResult, logger);
logger.Info($"ServerAction rotate_config: applied ConfigVersion={fetched.ConfigVersion} to the running agent.");
return true;
}
catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
},
// On-demand request mid-enrollment — the session has no outcome yet. Null keeps
@@ -0,0 +1,231 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// End-to-end authorization effect of identity binding through <see cref="PolicyEnforcementMiddleware"/>:
/// a validly-signed token from a FOREIGN Entra tenant (the API accepts every tenant) whose upn matches a
/// GlobalAdmins / DelegatedAdmins row must resolve NO platform or delegated role — and likewise a token
/// from the home tenant under a different object id, a token without an oid, and any UPN whose role row
/// has no binding at all. Also pins the ordering inside <see cref="DelegatedAdminService"/>: binding is
/// checked before the Pro entitlement gate, and an unbound caller never triggers the edition lookup.
/// </summary>
public class AdminIdentityBindingAuthorizationTests
{
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
private const string AttackerTenant = "22222222-2222-2222-2222-222222222222";
private const string ManagedTenant = "33333333-3333-3333-3333-333333333333";
private const string GaUpn = "cloudadmin@vendor.example";
private const string MspUpn = "msp@partner.example";
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
private const string OtherOid = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
private sealed class Harness
{
public required PolicyEnforcementMiddleware Middleware { get; init; }
public required Mock<IAdminRepository> Repo { get; init; }
}
private static Harness Build(AdminIdentityBinding? binding, string? globalRole = null, string? delegatedTenant = null)
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync(globalRole);
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(
delegatedTenant == null
? new List<DelegatedAdminEntry>()
: new List<DelegatedAdminEntry>
{
new()
{
Upn = MspUpn, TenantId = delegatedTenant.ToLowerInvariant(),
Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true,
Status = Constants.DelegatedStatus.Active, Source = Constants.DelegatedSource.OperatorGranted,
},
});
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny<string>())).ReturnsAsync(() => binding);
repo.Setup(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((string _, string tid, string oid) =>
{
if (binding != null && !binding.IsObjectIdPinned && binding.TenantId == tid)
binding.ObjectId = oid;
return binding;
});
var configRepo = new Mock<IConfigRepository>();
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync((TenantConfiguration?)null);
var cache = new MemoryCache(new MemoryCacheOptions());
var bindings = new AdminIdentityBindingService(repo.Object, cache, NullLogger<AdminIdentityBindingService>.Instance);
var globalAdmin = new GlobalAdminService(repo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance);
var delegatedAdmin = new DelegatedAdminService(
repo.Object, bindings,
new StubTenantEntitlementService(TenantEdition.Pro),
cache, NullLogger<DelegatedAdminService>.Instance);
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
var tenantConfig = new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
return new Harness
{
Middleware = new PolicyEnforcementMiddleware(
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin, tenantAdmins, tenantConfig),
Repo = repo,
};
}
private static ClaimsPrincipal Token(string tenantId, string upn, string? oid = Oid)
{
var claims = new List<Claim> { new("tid", tenantId), new("upn", upn) };
if (oid != null) claims.Add(new Claim("oid", oid));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
private static AdminIdentityBinding Bound(string upn, string tenantId, string objectId = Oid)
=> new() { Upn = upn, TenantId = tenantId, ObjectId = objectId };
// ── Global Admin tier ─────────────────────────────────────────────────────────
[Fact]
public async Task BoundGlobalAdmin_FromHomeTenant_IsAdmitted()
{
var h = Build(Bound(GaUpn, HomeTenant), globalRole: Constants.GlobalRoles.GlobalAdmin);
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn));
Assert.True(r.Allowed);
Assert.True(r.Context!.IsGlobalAdmin);
}
[Theory]
[InlineData("GET", "/api/global/raw/tables")] // GlobalAdminOnly — raw table dump incl. tenant secrets
[InlineData("POST", "/api/auth/global-admins")] // GlobalAdminOnly — persist access
[InlineData("GET", "/api/config/all")] // GlobalReadOrDelegatedSubset
[InlineData("GET", "/api/auth/global-admins")] // GlobalReadOrAdmin
public async Task ForeignTenantToken_WithGlobalAdminUpn_IsDenied(string method, string path)
{
// The finding: attacker registers the lapsed GA domain in their own tenant, creates the same UPN,
// signs in (any tenant is accepted) — the token is genuine, only the tid differs.
var h = Build(Bound(GaUpn, HomeTenant), globalRole: Constants.GlobalRoles.GlobalAdmin);
var r = await h.Middleware.DecideAsync(method, path, null, Token(AttackerTenant, GaUpn));
Assert.False(r.Allowed, $"{method} {path} must deny a foreign-tenant token even with a matching GA UPN");
}
[Fact]
public async Task ForeignTenantToken_WithGlobalAdminUpn_CannotReachAnotherTenant()
{
// Cross-tenant read on a tenant-scoped route: no GlobalScope ⇒ the cross-tenant guard blocks.
var h = Build(Bound(GaUpn, HomeTenant), globalRole: Constants.GlobalRoles.GlobalAdmin);
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{ManagedTenant}", null, Token(AttackerTenant, GaUpn));
Assert.False(r.Allowed);
}
[Fact]
public async Task HomeTenantToken_DifferentObjectId_IsDenied()
{
// UPN recycled inside the home tenant (User Administrator re-assigns it to another account).
var h = Build(Bound(GaUpn, HomeTenant, Oid), globalRole: Constants.GlobalRoles.GlobalAdmin);
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, OtherOid));
Assert.False(r.Allowed);
}
[Fact]
public async Task TokenWithoutObjectId_ResolvesNoPlatformRole()
{
var h = Build(Bound(GaUpn, HomeTenant), globalRole: Constants.GlobalRoles.GlobalAdmin);
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, oid: null));
Assert.False(r.Allowed);
}
[Fact]
public async Task UnboundGlobalAdminRow_IsInert()
{
// Legacy row, no binding yet: fail-closed until the operator seeds the binding — never "first come".
var h = Build(binding: null, globalRole: Constants.GlobalRoles.GlobalAdmin);
Assert.False((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn))).Allowed);
Assert.False((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(AttackerTenant, GaUpn))).Allowed);
}
[Fact]
public async Task UnpinnedBinding_HomeTenantFirstSignIn_Pins_ThenForeignAndRecycledAreDenied()
{
var h = Build(Bound(GaUpn, HomeTenant, objectId: ""), globalRole: Constants.GlobalRoles.GlobalAdmin);
// First sign-in from the bound tenant pins the oid and is admitted.
Assert.True((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, Oid))).Allowed);
h.Repo.Verify(r => r.TryPinIdentityObjectIdAsync(GaUpn, HomeTenant, Oid), Times.Once);
// From then on: same account ok, another account in the home tenant denied, foreign tenant denied.
Assert.True((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, Oid))).Allowed);
Assert.False((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, OtherOid))).Allowed);
Assert.False((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(AttackerTenant, GaUpn, Oid))).Allowed);
}
[Fact]
public async Task UnpinnedBinding_ForeignTenantCannotClaimThePin()
{
var h = Build(Bound(GaUpn, HomeTenant, objectId: ""), globalRole: Constants.GlobalRoles.GlobalAdmin);
Assert.False((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(AttackerTenant, GaUpn, OtherOid))).Allowed);
h.Repo.Verify(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
// The real admin still gets the pin afterwards.
Assert.True((await h.Middleware.DecideAsync("GET", "/api/global/raw/tables", null, Token(HomeTenant, GaUpn, Oid))).Allowed);
}
// ── Delegated (MSP) tier ──────────────────────────────────────────────────────
[Fact]
public async Task BoundDelegatedAdmin_FromHomeTenant_IsRescuedOnManagedTenantRead()
{
var h = Build(Bound(MspUpn, HomeTenant), delegatedTenant: ManagedTenant);
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{ManagedTenant}", null, Token(HomeTenant, MspUpn));
Assert.True(r.Allowed);
Assert.True(r.Context!.IsDelegatedReader);
}
[Fact]
public async Task ForeignTenantToken_WithDelegatedUpn_ResolvesEmptyScope()
{
// The finding's MSP variant: an attacker tenant (Pro via self-service trial) mints the delegated UPN.
var h = Build(Bound(MspUpn, HomeTenant), delegatedTenant: ManagedTenant);
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{ManagedTenant}", null, Token(AttackerTenant, MspUpn));
Assert.False(r.Allowed);
Assert.False((await h.Middleware.DecideAsync("GET", "/api/config/all", null, Token(AttackerTenant, MspUpn))).Allowed);
}
[Fact]
public async Task DelegatedService_ChecksBindingBeforeEntitlementGate()
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(new List<DelegatedAdminEntry>
{
new()
{
Upn = MspUpn, TenantId = ManagedTenant, Role = Constants.DelegatedRoles.DelegatedReader,
IsEnabled = true, Status = Constants.DelegatedStatus.Active, Source = Constants.DelegatedSource.OperatorGranted,
},
});
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
var editionLookups = 0;
var svc = new DelegatedAdminService(
repo.Object,
new StubAdminIdentityBindingService(bound: false),
new StubTenantEntitlementService(_ => { editionLookups++; return TenantEdition.Pro; }),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<DelegatedAdminService>.Instance);
var scope = await svc.GetScopeAsync(new AdminIdentity(MspUpn, AttackerTenant, Oid));
Assert.True(scope.IsEmpty);
Assert.Equal(0, editionLookups);
}
}
@@ -0,0 +1,242 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins <see cref="AdminIdentityBindingService"/> — the one place that decides whether a caller IS the
/// identity a cross-tenant-role UPN was granted for. The API accepts tokens from any Entra tenant and
/// upn/preferred_username are mutable, so a UPN string alone must never resolve a platform or delegated
/// role: only the (upn, tid, oid) triple matching the stored binding does. Covers the verdict matrix
/// (no binding / tenant mismatch / first-sign-in pin / pinned mismatch / concurrent-pin loss), the cache
/// invalidation after a pin, and the grant-time conflict rules (a grant never silently re-homes a UPN).
/// </summary>
public class AdminIdentityBindingServiceTests
{
private const string Upn = "admin@vendor.example";
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
private const string ForeignTenant = "22222222-2222-2222-2222-222222222222";
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
private const string OtherOid = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
private static (AdminIdentityBindingService Svc, Mock<IAdminRepository> Repo) Build(AdminIdentityBinding? stored)
{
var repo = new Mock<IAdminRepository>();
// Stateful stand-in for the row: reads return `stored`, a pin mutates it under the repo's contract
// (only onto an unpinned row homed in the caller's tenant), upserts replace it.
repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny<string>())).ReturnsAsync(() => stored);
repo.Setup(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((string _, string tid, string oid) =>
{
if (stored != null && !stored.IsObjectIdPinned && stored.TenantId == tid)
stored.ObjectId = oid;
return stored;
});
repo.Setup(r => r.UpsertIdentityBindingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>()))
.ReturnsAsync((string upn, string tid, string? oid, string by) =>
{
stored = new AdminIdentityBinding { Upn = upn, TenantId = tid, ObjectId = oid ?? string.Empty, BoundBy = by };
return true;
});
var svc = new AdminIdentityBindingService(
repo.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<AdminIdentityBindingService>.Instance);
return (svc, repo);
}
private static AdminIdentityBinding Binding(string tenantId, string objectId = "") =>
new() { Upn = Upn, TenantId = tenantId, ObjectId = objectId };
private static AdminIdentity Caller(string tenantId = HomeTenant, string oid = Oid) => new(Upn, tenantId, oid);
// ── Verdict matrix ────────────────────────────────────────────────────────────
[Fact]
public async Task NullIdentity_IsNeverBound_AndReadsNothing()
{
var (svc, repo) = Build(Binding(HomeTenant, Oid));
Assert.False(await svc.IsBoundAsync(null));
repo.Verify(r => r.GetIdentityBindingAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task NoBindingRow_IsNotBound()
{
// A legacy role row without a binding is inert — fail-closed, never "bind whoever shows up first".
var (svc, _) = Build(stored: null);
Assert.False(await svc.IsBoundAsync(Caller()));
}
[Fact]
public async Task ForeignTenant_WithMatchingUpn_IsNotBound()
{
// The finding's primary exploit: same UPN string minted by another Entra tenant (domain re-registered,
// or a lookalike verified elsewhere). The tid is the immutable discriminator.
var (svc, repo) = Build(Binding(HomeTenant, Oid));
Assert.False(await svc.IsBoundAsync(Caller(tenantId: ForeignTenant)));
// And it must not pin anything onto the real admin's binding.
repo.Verify(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ForeignTenant_AgainstUnpinnedBinding_DoesNotPin()
{
// Unpinned binding + foreign-tenant token: tenant mismatch is checked BEFORE the pin, so the
// attacker cannot "claim" the object-id slot of an admin who has not signed in yet.
var (svc, repo) = Build(Binding(HomeTenant));
Assert.False(await svc.IsBoundAsync(Caller(tenantId: ForeignTenant)));
repo.Verify(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task PinnedBinding_MatchingTidAndOid_IsBound()
{
var (svc, _) = Build(Binding(HomeTenant, Oid));
Assert.True(await svc.IsBoundAsync(Caller()));
}
[Fact]
public async Task PinnedBinding_SameTenantDifferentOid_IsNotBound()
{
// UPN recycling inside the home tenant: a User Administrator re-assigns the UPN to another account.
// Same tid, new oid — refused until an operator explicitly rebinds.
var (svc, _) = Build(Binding(HomeTenant, Oid));
Assert.False(await svc.IsBoundAsync(Caller(oid: OtherOid)));
}
[Fact]
public async Task UnpinnedBinding_FirstSignInFromHomeTenant_PinsAndIsBound_ThenEnforced()
{
var (svc, repo) = Build(Binding(HomeTenant));
Assert.True(await svc.IsBoundAsync(Caller()));
repo.Verify(r => r.TryPinIdentityObjectIdAsync(Upn, HomeTenant, Oid), Times.Once);
// Pinned now: the same account stays bound, any other account in the same tenant is refused.
Assert.True(await svc.IsBoundAsync(Caller()));
Assert.False(await svc.IsBoundAsync(Caller(oid: OtherOid)));
// The pin happened exactly once — later calls verify against the stored (cached) binding.
repo.Verify(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task ConcurrentPin_LostRace_VerifiesAgainstStoredWinner()
{
// Two accounts in the home tenant race the first sign-in; the repository's conditional update lets
// exactly one win. The loser must be judged against what was STORED, never against its own intent.
var (svc, repo) = Build(Binding(HomeTenant));
repo.Setup(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(Binding(HomeTenant, OtherOid)); // someone else won
Assert.False(await svc.IsBoundAsync(Caller()));
}
[Fact]
public async Task PinResult_Null_MeansBindingVanished_IsNotBound()
{
// Binding removed between the cached read and the pin (operator DELETE racing the sign-in).
var (svc, repo) = Build(Binding(HomeTenant));
repo.Setup(r => r.TryPinIdentityObjectIdAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((AdminIdentityBinding?)null);
Assert.False(await svc.IsBoundAsync(Caller()));
}
[Fact]
public async Task Verdict_IsCached_ButPinInvalidates()
{
var (svc, repo) = Build(Binding(HomeTenant, Oid));
Assert.True(await svc.IsBoundAsync(Caller()));
Assert.True(await svc.IsBoundAsync(Caller()));
repo.Verify(r => r.GetIdentityBindingAsync(It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task Rebind_InvalidatesCache_SoNewTenantTakesEffectImmediately()
{
var (svc, _) = Build(Binding(HomeTenant, Oid));
Assert.True(await svc.IsBoundAsync(Caller()));
await svc.RebindAsync(Upn, ForeignTenant, OtherOid, "ga@vendor.example");
Assert.False(await svc.IsBoundAsync(Caller()));
Assert.True(await svc.IsBoundAsync(Caller(tenantId: ForeignTenant, oid: OtherOid)));
}
// ── Grant-time binding (EnsureBoundAsync) ─────────────────────────────────────
[Fact]
public async Task Ensure_NoExistingBinding_CreatesIt()
{
var (svc, repo) = Build(stored: null);
var b = await svc.EnsureBoundAsync(Upn, HomeTenant, null, "ga@vendor.example");
Assert.Equal(HomeTenant, b.TenantId);
Assert.False(b.IsObjectIdPinned);
repo.Verify(r => r.UpsertIdentityBindingAsync(Upn, HomeTenant, null, "ga@vendor.example"), Times.Once);
}
[Fact]
public async Task Ensure_SameTenant_KeepsExistingPin_NoWrite()
{
// A second grant (another managed tenant / a group) for an already-bound UPN is a no-op on the binding.
var (svc, repo) = Build(Binding(HomeTenant, Oid));
var b = await svc.EnsureBoundAsync(Upn, HomeTenant, null, "ga@vendor.example");
Assert.Equal(Oid, b.ObjectId);
repo.Verify(r => r.UpsertIdentityBindingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Ensure_SameTenant_UnpinnedThenObjectIdSupplied_Upgrades()
{
var (svc, repo) = Build(Binding(HomeTenant));
var b = await svc.EnsureBoundAsync(Upn, HomeTenant, Oid, "ga@vendor.example");
Assert.Equal(Oid, b.ObjectId);
repo.Verify(r => r.UpsertIdentityBindingAsync(Upn, HomeTenant, Oid, "ga@vendor.example"), Times.Once);
}
[Fact]
public async Task Ensure_DifferentTenant_Conflicts_NeverRehomes()
{
// A grant must not be a covert rebind: moving a UPN to another tenant is an explicit PUT.
var (svc, repo) = Build(Binding(HomeTenant, Oid));
await Assert.ThrowsAsync<IdentityBindingConflictException>(
() => svc.EnsureBoundAsync(Upn, ForeignTenant, null, "ga@vendor.example"));
repo.Verify(r => r.UpsertIdentityBindingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Ensure_DifferentPinnedObjectId_Conflicts()
{
var (svc, _) = Build(Binding(HomeTenant, Oid));
await Assert.ThrowsAsync<IdentityBindingConflictException>(
() => svc.EnsureBoundAsync(Upn, HomeTenant, OtherOid, "ga@vendor.example"));
}
[Fact]
public async Task Ensure_NormalizesCase()
{
var (svc, repo) = Build(stored: null);
await svc.EnsureBoundAsync("Admin@Vendor.Example", HomeTenant.ToUpperInvariant(), Oid.ToUpperInvariant(), "ga@vendor.example");
repo.Verify(r => r.UpsertIdentityBindingAsync(Upn, HomeTenant, Oid, "ga@vendor.example"), Times.Once);
}
// ── AdminIdentity construction ────────────────────────────────────────────────
[Theory]
[InlineData(null, HomeTenant, Oid)]
[InlineData(Upn, "", Oid)]
[InlineData(Upn, HomeTenant, " ")]
public void AdminIdentity_Create_RequiresAllThree(string? upn, string? tid, string? oid)
=> Assert.Null(AdminIdentity.Create(upn, tid, oid));
[Fact]
public void AdminIdentity_Create_Lowercases()
{
var id = AdminIdentity.Create("Admin@Vendor.Example", HomeTenant.ToUpperInvariant(), Oid.ToUpperInvariant());
Assert.Equal(new AdminIdentity(Upn, HomeTenant, Oid), id);
}
}
@@ -0,0 +1,104 @@
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;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins <see cref="AdminIdentityResolver"/> — what makes granting a cross-tenant role "type the UPN":
/// sign-in history first (tid + oid, both from validated tokens), then UPN domain → onboarded tenant
/// (tid only, oid pinned on first sign-in), and NO answer when the history is ambiguous (same UPN under
/// two tenants — exactly the situation the binding exists for) or the domain is unknown.
/// </summary>
public class AdminIdentityResolverTests
{
private const string Upn = "msp@partner.example";
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string OidOld = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
private const string OidNew = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
private static AdminIdentityResolver Build(
IEnumerable<UserSignInIdentity>? signIns = null,
params (string TenantId, string Domain)[] tenants)
{
var metrics = new Mock<IMetricsRepository>();
metrics.Setup(m => m.GetSignInIdentitiesByUpnAsync(It.IsAny<string>()))
.ReturnsAsync((signIns ?? Array.Empty<UserSignInIdentity>()).ToList());
var configRepo = new Mock<IConfigRepository>();
configRepo.Setup(r => r.GetAllTenantConfigurationsAsync())
.ReturnsAsync(tenants.Select(t => new TenantConfiguration { TenantId = t.TenantId, DomainName = t.Domain }).ToList());
var configService = new TenantConfigurationService(
configRepo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
return new AdminIdentityResolver(metrics.Object, configService, NullLogger<AdminIdentityResolver>.Instance);
}
private static UserSignInIdentity SignIn(string tid, string oid, int daysAgo) => new()
{
TenantId = tid, ObjectId = oid, LastLoginAt = DateTime.UtcNow.AddDays(-daysAgo), LoginCount = 1,
};
[Fact]
public async Task SignInHistory_SingleTenant_ResolvesTidAndOid()
{
var r = await Build(new[] { SignIn(TenantA, OidOld, 3) }).ResolveAsync("MSP@Partner.Example");
Assert.NotNull(r);
Assert.Equal(TenantA, r!.TenantId);
Assert.Equal(OidOld, r.ObjectId);
Assert.Equal(ResolvedAdminIdentity.SourceSignIn, r.Source);
}
[Fact]
public async Task SignInHistory_SameTenantTwoOids_TakesMostRecent()
{
// Account re-created in the same tenant: the newer oid is the one that will sign in next.
var r = await Build(new[] { SignIn(TenantA, OidOld, 30), SignIn(TenantA, OidNew, 1) }).ResolveAsync(Upn);
Assert.Equal(OidNew, r!.ObjectId);
}
[Fact]
public async Task SignInHistory_TwoTenants_IsAmbiguous_NotResolved_EvenIfDomainMatches()
{
// The same UPN string seen under two tenants is the attack shape; never auto-pick, even when the
// domain fallback would have an answer.
var r = await Build(new[] { SignIn(TenantA, OidOld, 3), SignIn(TenantB, OidNew, 1) }, (TenantA, "partner.example"))
.ResolveAsync(Upn);
Assert.Null(r);
}
[Fact]
public async Task NoSignIns_DomainOfOnboardedTenant_ResolvesTidOnly()
{
var r = await Build(null, (TenantA, "Partner.Example"), (TenantB, "other.example")).ResolveAsync(Upn);
Assert.NotNull(r);
Assert.Equal(TenantA, r!.TenantId);
Assert.Null(r.ObjectId);
Assert.Equal(ResolvedAdminIdentity.SourceDomain, r.Source);
}
[Fact]
public async Task NoSignIns_TwoTenantsClaimTheDomain_NotResolved()
{
var r = await Build(null, (TenantA, "partner.example"), (TenantB, "partner.example")).ResolveAsync(Upn);
Assert.Null(r);
}
[Fact]
public async Task NoSignIns_UnknownDomain_NotResolved()
{
var r = await Build(null, (TenantA, "other.example")).ResolveAsync(Upn);
Assert.Null(r);
}
[Theory]
[InlineData("nodomain")]
[InlineData("trailing@")]
public async Task MalformedUpn_NotResolved(string upn)
=> Assert.Null(await Build(null, (TenantA, "partner.example")).ResolveAsync(upn));
}
@@ -0,0 +1,92 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Stored-XSS guard for analyze-rule relatedDocs: the author-controlled URL is rendered as
/// an anchor href for every viewer (including cross-tenant admins), so only absolute
/// http(s) URLs may persist. Create and Update both reject anything else with an
/// <see cref="ArgumentException"/> (400), and drop blank entries silently.
/// </summary>
public class AnalyzeRuleRelatedDocsUrlTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
[Theory]
[InlineData("https://learn.microsoft.com/x", true)]
[InlineData("http://example.com/", true)]
[InlineData(" https://example.com/a?b=c#d ", true)]
[InlineData("javascript:alert(1)", false)]
[InlineData("JavaScript:fetch('https://example.com/'+localStorage.token)", false)]
[InlineData("data:text/html,<script>alert(1)</script>", false)]
[InlineData("vbscript:msgbox(1)", false)]
[InlineData("file:///etc/passwd", false)]
[InlineData("/relative/path", false)]
[InlineData("example.com", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsAbsoluteHttpUrl_allows_only_absolute_http_and_https(string? url, bool expected)
{
Assert.Equal(expected, AnalyzeRuleService.IsAbsoluteHttpUrl(url));
}
[Fact]
public async Task CreateRuleAsync_rejects_javascript_url_and_never_persists()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
var rule = MakeRule(new RelatedDoc { Title = "Microsoft Docs: fix this error", Url = "javascript:alert(1)" });
var ex = await Assert.ThrowsAsync<ArgumentException>(() => service.CreateRuleAsync(TenantId, rule));
Assert.Contains("javascript:alert(1)", ex.Message);
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task UpdateRuleAsync_rejects_data_url_for_custom_rule()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
var rule = MakeRule(new RelatedDoc { Title = "Docs", Url = "data:text/html,<script>alert(1)</script>" });
await Assert.ThrowsAsync<ArgumentException>(() => service.UpdateRuleAsync(TenantId, rule));
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task CreateRuleAsync_keeps_https_docs_and_drops_blank_entries()
{
AnalyzeRule? stored = null;
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), TenantId))
.Callback<AnalyzeRule, string>((r, _) => stored = r)
.ReturnsAsync(true);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
var rule = MakeRule(
new RelatedDoc { Title = "Good", Url = "https://learn.microsoft.com/x" },
new RelatedDoc { Title = "", Url = "" }, // editor "+ Add Link" default, untouched
new RelatedDoc { Title = "Blank", Url = " " });
Assert.True(await service.CreateRuleAsync(TenantId, rule));
Assert.NotNull(stored);
var doc = Assert.Single(stored!.RelatedDocs);
Assert.Equal("https://learn.microsoft.com/x", doc.Url);
}
private static AnalyzeRule MakeRule(params RelatedDoc[] docs) => new()
{
RuleId = "TENANT-DOCS-URL-001",
IsBuiltIn = false,
IsCommunity = false,
RelatedDocs = docs.ToList(),
};
}
@@ -1,4 +1,5 @@
using AutopilotMonitor.Functions.Functions.Infrastructure;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -39,16 +40,18 @@ public class AuthFunctionSideEffectTests
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
{ CallBase = false };
var bindings = new StubAdminIdentityBindingService(bound: true);
var globalAdminMock = new Mock<GlobalAdminService>(
adminRepo, cache, Mock.Of<ILogger<GlobalAdminService>>())
adminRepo, bindings, cache, Mock.Of<ILogger<GlobalAdminService>>())
{ CallBase = false };
var delegatedAdminMock = new Mock<DelegatedAdminService>(
adminRepo,
bindings,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache, Mock.Of<ILogger<DelegatedAdminService>>())
{ CallBase = false };
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>()))
.ReturnsAsync(DelegatedScope.Empty);
_tenantAdminsMock = new Mock<TenantAdminsService>(
@@ -93,7 +96,9 @@ public class AuthFunctionSideEffectTests
_autoApproveEnqueuerMock.Object,
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()));
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
new Mock<AdminIdentityResolver>(
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object);
// Default: all fire-and-forget calls succeed
_tenantConfigMock
@@ -35,7 +35,7 @@ public class AzureQueueImeMsiArchiveProducerTests
Assert.Equal(2, sentBodies.Count);
var roundTripped = JsonConvert.DeserializeObject<ImeMsiArchiveEnvelope>(sentBodies[0])!;
Assert.Equal(Version, roundTripped.Version);
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, roundTripped.MsiDownloadUrl);
Assert.Equal(ImeMsiArchiver.FallbackMsiUrls[1], roundTripped.MsiDownloadUrl);
Assert.Equal("11111111-1111-1111-1111-111111111111", roundTripped.TenantId);
// Queue existence is ensured once, then latched — not re-checked per message.
queue.Verify(q => q.CreateIfNotExistsAsync(
@@ -92,7 +92,7 @@ public class AzureQueueImeMsiArchiveProducerTests
private static ImeMsiArchiveEnvelope Envelope() => new()
{
Version = Version,
MsiDownloadUrl = ImeMsiArchiver.CanonicalMsiUrl,
MsiDownloadUrl = ImeMsiArchiver.FallbackMsiUrls[1],
MsiMatchedBy = "productVersion",
TenantId = "11111111-1111-1111-1111-111111111111",
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
@@ -119,6 +119,53 @@ public class CmTraceSkewTripwireTests
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
}
[Fact]
public void Silent_OnBacklogReplayBurstWhoseMedianLandsOnGrid()
{
// Field case 2026-08-28 (session d832560a): the agent died on auth failures, the relaunch
// re-tailed the IME log and uploaded 508 lines in one burst with a continuum of ages
// (46…733 min, quartiles 52 / 92 / 100) — median 91 min, i.e. 6×15 by pure chance.
// The other side was live. The per-sample grid-conformity guard must keep this silent.
var scan = MakeScan(imeDeltaMinutes: 0.1, otherDeltaMinutes: 0.1, imeSamples: 100, imeBatches: 80);
void AddRamp(double from, double to, int count)
{
for (int i = 0; i < count; i++)
scan.ImeDeltaMinutes.Add(from + i * (to - from) / count);
}
AddRamp(46.0, 88.0, 125);
AddRamp(88.0, 100.0, 250);
AddRamp(100.0, 733.0, 125);
var medianIme = scan.ImeDeltaMinutes.OrderBy(v => v).ElementAt(scan.ImeDeltaMinutes.Count / 2);
Assert.InRange(medianIme, 88.0, 92.0); // sanity: the median really does sit near 6×15
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
Assert.True(CmTraceSkewTripwire.ComputeGridConformantFraction(scan.ImeDeltaMinutes, 0.1) < 0.5);
}
[Fact]
public void Fires_OnTwoWriterErasBothOnGrid()
{
// e9753578 shape: one file mixing PDT (−420) and CEST (+120) lines relative to the agent
// zone. Every line is individually on the grid, so the conformity guard must NOT mask it.
var scan = MakeScan(imeDeltaMinutes: -420.0, otherDeltaMinutes: 0.0, imeSamples: 65);
for (int i = 0; i < 35; i++)
scan.ImeDeltaMinutes.Add(120.0);
var result = CmTraceSkewTripwire.Evaluate(scan);
Assert.NotNull(result);
Assert.Equal(-28, result!.GridSteps);
Assert.Equal(1.0, result.GridConformantFraction, 3);
}
[Fact]
public void GridConformantFraction_CountsZeroAndAnyMultiple()
{
var deltas = new List<double> { 0.5, 15.9, -30.1, 7.5, 37.5 }; // last two are off-grid
Assert.Equal(0.6, CmTraceSkewTripwire.ComputeGridConformantFraction(deltas, 0.0), 3);
Assert.Equal(0, CmTraceSkewTripwire.ComputeGridConformantFraction(new List<double>(), 0.0));
}
// ── Bias suppression predicate ─────────────────────────────────────────
[Fact]
@@ -0,0 +1,97 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The targeted push signal: "someone clicked Collect Logs and switched on Hosted upload
/// through the quick-config dialog" must land as its own ops event type, while every other
/// enable path keeps the generic type — an alert rule filters by EventType only.
/// </summary>
public class CollectLogsQuickConfigOpsEventTests
{
private static (OpsEventService Service, List<OpsEventEntry> Saved) Rig()
{
var saved = new List<OpsEventEntry>();
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (saved) saved.Add(e); })
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
return (new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch), saved);
}
private static DiagnosticsUploadConfigChange EnableFlip() =>
DiagnosticsUploadConfigChange.Detect(
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off" },
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "OnFailure", DiagnosticsUploadDestination = "Hosted" })!;
[Theory]
[InlineData("collect-logs", UpdateTenantConfigurationFunction.CollectLogsSource)]
[InlineData("COLLECT-LOGS", UpdateTenantConfigurationFunction.CollectLogsSource)]
[InlineData(null, "portal-put")]
[InlineData("", "portal-put")]
[InlineData("something-else", "portal-put")]
public void ResolveWriteSource_only_honours_the_allow_listed_intent(string? intent, string expected)
{
Assert.Equal(expected, UpdateTenantConfigurationFunction.ResolveWriteSource(intent));
}
[Fact]
public async Task Quick_config_enable_lands_as_its_own_event_type()
{
var (service, saved) = Rig();
await service.RecordDiagnosticsUploadConfigChangedAsync(
"t1", "contoso.com", EnableFlip(), "admin@contoso.com", UpdateTenantConfigurationFunction.CollectLogsSource);
var evt = Assert.Single(saved);
Assert.Equal("CollectLogsQuickConfigEnabled", evt.EventType);
Assert.Equal(OpsEventCategory.Tenant, evt.Category);
Assert.Contains("admin@contoso.com", evt.Message);
Assert.Contains("Hosted", evt.Message);
}
[Theory]
[InlineData("portal-put")]
[InlineData("mcp-patch")]
public async Task Other_enable_paths_keep_the_generic_event_type(string source)
{
var (service, saved) = Rig();
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", "contoso.com", EnableFlip(), "admin@contoso.com", source);
Assert.Equal("DiagnosticsUploadEnabled", Assert.Single(saved).EventType);
}
[Fact]
public async Task Quick_config_source_on_a_disable_is_the_generic_disabled_type()
{
var (service, saved) = Rig();
var disable = DiagnosticsUploadConfigChange.Detect(
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Always", DiagnosticsUploadDestination = "Hosted" },
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off", DiagnosticsUploadDestination = "Hosted" })!;
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", null, disable, "ga@x", UpdateTenantConfigurationFunction.CollectLogsSource);
Assert.Equal("DiagnosticsUploadDisabled", Assert.Single(saved).EventType);
}
}
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
Assert.Equal(BackupOutcome.Success, result.Outcome);
Assert.True(store.ManifestWritten, "manifest must be written LAST");
Assert.Equal(18, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups + SessionAnnotations)
Assert.Equal(19, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups + SessionAnnotations + AdminIdentityBindings)
Assert.All(result.Manifest.Tables, t =>
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
}
@@ -68,6 +68,32 @@ public class CveIndexEntityMergeTests
Assert.Equal("critical", e.GetString("OverallRisk"));
}
[Fact]
public void Merge_EpssMaxes_PriorityMaxes_AndUnscoredWritesNoEpssColumn()
{
var scoredLow = Vuln("CVE-1", 5.0, "MEDIUM"); scoredLow["epssScore"] = 0.02; scoredLow["priority"] = "track";
var scoredHigh = Vuln("CVE-1", 4.0, "MEDIUM"); scoredHigh["epssScore"] = 0.35; scoredHigh["priority"] = "attend";
var unscored = Vuln("CVE-1", 3.0, "LOW"); unscored["priority"] = "track";
var e = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, new List<Dictionary<string, object>>
{
Finding("App A", "medium", unscored),
Finding("App B", "medium", scoredLow),
Finding("App C", "medium", scoredHigh),
}));
Assert.Equal(0.35, e.GetDouble("EpssScore"));
Assert.Equal("attend", e.GetString("Priority"));
// A CVE nobody scored must not carry a fake 0.0 — the aggregate treats absent as "unknown".
var none = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, new List<Dictionary<string, object>>
{
Finding("App A", "low", Vuln("CVE-2", 3.0, "LOW")),
}));
Assert.False(none.ContainsKey("EpssScore"));
Assert.Equal("", none.GetString("Priority"));
}
[Fact]
public void DistinctCves_StayDistinct_AndInvalidEntriesAreSkipped()
{
@@ -23,6 +23,10 @@ public class DelegatedAdminEditionGateTests
private const string CommunityHomeTenant = "22222222-2222-2222-2222-222222222222";
private const string ManagedTenantA = "33333333-3333-3333-3333-333333333333";
private const string ManagedTenantB = "44444444-4444-4444-4444-444444444444";
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
/// <summary>Caller identity homed in <paramref name="homeTenantId"/>; null/blank ⇒ incomplete identity (null).</summary>
private static AdminIdentity? Id(string? homeTenantId) => AdminIdentity.Create(Upn, homeTenantId, Oid);
private static DelegatedAdminEntry Row(string tenantId) => new()
{
@@ -43,6 +47,8 @@ public class DelegatedAdminEditionGateTests
.ReturnsAsync(new List<TenantGroupAssignment>());
return new DelegatedAdminService(
repo.Object,
// Binding stubbed to "bound": these tests pin the EDITION gate, not the identity check.
new StubAdminIdentityBindingService(bound: true),
new StubTenantEntitlementService(
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
new MemoryCache(new MemoryCacheOptions()),
@@ -55,7 +61,7 @@ public class DelegatedAdminEditionGateTests
// Managed targets resolve Community in the stub — they must NOT be dropped.
var svc = Build(Row(ManagedTenantA), Row(ManagedTenantB));
var scope = await svc.GetScopeAsync(Upn, EnterpriseHomeTenant);
var scope = await svc.GetScopeAsync(Id(EnterpriseHomeTenant));
Assert.True(scope.Covers(ManagedTenantA));
Assert.True(scope.Covers(ManagedTenantB));
@@ -66,7 +72,7 @@ public class DelegatedAdminEditionGateTests
{
var svc = Build(Row(ManagedTenantA), Row(ManagedTenantB));
var scope = await svc.GetScopeAsync(Upn, CommunityHomeTenant);
var scope = await svc.GetScopeAsync(Id(CommunityHomeTenant));
Assert.True(scope.IsEmpty);
}
@@ -76,8 +82,8 @@ public class DelegatedAdminEditionGateTests
{
var svc = Build(Row(ManagedTenantA));
Assert.True((await svc.GetScopeAsync(Upn, null)).IsEmpty);
Assert.True((await svc.GetScopeAsync(Upn, "")).IsEmpty);
Assert.True((await svc.GetScopeAsync(Id(null))).IsEmpty);
Assert.True((await svc.GetScopeAsync(Id(""))).IsEmpty);
}
[Fact]
@@ -89,9 +95,9 @@ public class DelegatedAdminEditionGateTests
// practice but the cache must stay pure either way).
var svc = Build(Row(ManagedTenantA));
Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
Assert.True((await svc.GetScopeAsync(Upn, EnterpriseHomeTenant)).Covers(ManagedTenantA));
Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
Assert.True((await svc.GetScopeAsync(Id(CommunityHomeTenant))).IsEmpty);
Assert.True((await svc.GetScopeAsync(Id(EnterpriseHomeTenant))).Covers(ManagedTenantA));
Assert.True((await svc.GetScopeAsync(Id(CommunityHomeTenant))).IsEmpty);
}
[Fact]
@@ -117,14 +123,16 @@ public class DelegatedAdminEditionGateTests
var svc = new DelegatedAdminService(
repo.Object,
// Binding stubbed to "bound": these tests pin the EDITION gate, not the identity check.
new StubAdminIdentityBindingService(bound: true),
new StubTenantEntitlementService(
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<DelegatedAdminService>.Instance);
Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
Assert.True((await svc.GetScopeAsync(Id(CommunityHomeTenant))).IsEmpty);
var enterpriseScope = await svc.GetScopeAsync(Upn, EnterpriseHomeTenant);
var enterpriseScope = await svc.GetScopeAsync(Id(EnterpriseHomeTenant));
Assert.True(enterpriseScope.Covers(ManagedTenantA));
Assert.True(enterpriseScope.Covers(ManagedTenantB));
}
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -16,6 +17,8 @@ namespace AutopilotMonitor.Functions.Tests;
public class DelegatedAdminServiceTests
{
private const string Upn = "msp-admin@partner.example";
private const string HomeTenant = "99999999-9999-9999-9999-999999999999";
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
@@ -34,14 +37,20 @@ public class DelegatedAdminServiceTests
var cache = new MemoryCache(new MemoryCacheOptions());
// Entitlements stubbed to Enterprise for every tenant — these tests pin ROLE resolution;
// the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
// Binding stubbed to "bound" — these tests pin ROW resolution; the identity check has its own
// tests (AdminIdentityBindingServiceTests / AdminIdentityBindingAuthorizationTests).
var svc = new DelegatedAdminService(
repo.Object,
new StubAdminIdentityBindingService(bound: true),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache,
NullLogger<DelegatedAdminService>.Instance);
return (svc, repo);
}
/// <summary>The caller identity for a UPN: homed in <see cref="HomeTenant"/> with <see cref="Oid"/>.</summary>
private static AdminIdentity Id(string upn) => new(upn.ToLowerInvariant(), HomeTenant, Oid);
private static TenantGroupAssignment Assignment(
string groupId,
string role = Constants.DelegatedRoles.DelegatedReader,
@@ -78,13 +87,17 @@ public class DelegatedAdminServiceTests
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(rows.ToList());
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task GetScope_BlankUpn_ReturnsEmpty(string? upn)
[InlineData(null, HomeTenant, Oid)]
[InlineData("", HomeTenant, Oid)]
[InlineData(" ", HomeTenant, Oid)]
[InlineData(Upn, null, Oid)]
[InlineData(Upn, HomeTenant, null)]
public async Task GetScope_IncompleteIdentity_ReturnsEmpty_WithoutTouchingStorage(string? upn, string? tid, string? oid)
{
// upn + tid + oid are all required: a token missing any of them (app-only, or a foreign IdP shape)
// resolves no delegated scope and does not even read the assignment rows.
var (svc, repo) = Build();
var scope = await svc.GetScopeAsync(upn);
var scope = await svc.GetScopeAsync(AdminIdentity.Create(upn, tid, oid));
Assert.True(scope.IsEmpty);
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Never);
}
@@ -93,7 +106,7 @@ public class DelegatedAdminServiceTests
public async Task GetScope_NoRows_ReturnsEmpty()
{
var (svc, _) = Build();
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.IsEmpty);
Assert.False(scope.Covers(TenantA));
}
@@ -104,7 +117,7 @@ public class DelegatedAdminServiceTests
var (svc, repo) = Build();
Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedReader));
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.Covers(TenantA));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
@@ -118,7 +131,7 @@ public class DelegatedAdminServiceTests
var (svc, repo) = Build();
Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedAdmin));
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.CanWrite(TenantA));
}
@@ -131,7 +144,7 @@ public class DelegatedAdminServiceTests
var (svc, repo) = Build();
Returns(repo, Row(TenantA, status: status));
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.False(scope.Covers(TenantA));
Assert.True(scope.IsEmpty);
@@ -143,7 +156,7 @@ public class DelegatedAdminServiceTests
var (svc, repo) = Build();
Returns(repo, Row(TenantA, enabled: false));
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.False(scope.Covers(TenantA));
}
@@ -154,7 +167,7 @@ public class DelegatedAdminServiceTests
var (svc, repo) = Build();
Returns(repo, Row(TenantA, role: ""));
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
}
@@ -165,7 +178,7 @@ public class DelegatedAdminServiceTests
var (svc, repo) = Build();
Returns(repo, Row(TenantA, role: "SuperRoot"));
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.False(scope.Covers(TenantA));
Assert.True(scope.IsEmpty);
@@ -179,7 +192,7 @@ public class DelegatedAdminServiceTests
Row(TenantA, Constants.DelegatedRoles.DelegatedReader),
Row(TenantB, Constants.DelegatedRoles.DelegatedAdmin));
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.Equal(2, scope.TenantIds.Count);
Assert.False(scope.CanWrite(TenantA));
@@ -192,7 +205,7 @@ public class DelegatedAdminServiceTests
var (svc, repo) = Build();
Returns(repo, Row(TenantA.ToLowerInvariant()));
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.Covers(TenantA.ToUpperInvariant()));
}
@@ -203,8 +216,8 @@ public class DelegatedAdminServiceTests
var (svc, repo) = Build();
Returns(repo, Row(TenantA));
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Id(Upn));
await svc.GetScopeAsync(Id(Upn));
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Once);
}
@@ -219,10 +232,11 @@ public class DelegatedAdminServiceTests
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(true);
await svc.GetScopeAsync(Upn); // primes cache
await svc.GetScopeAsync(Id(Upn)); // primes cache
await svc.UpsertAsync(Upn, TenantB, Constants.DelegatedRoles.DelegatedReader,
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, "ga@vendor.example");
await svc.GetScopeAsync(Upn); // must re-query after invalidation
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, "ga@vendor.example",
HomeTenant, null);
await svc.GetScopeAsync(Id(Upn)); // must re-query after invalidation
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Exactly(2));
}
@@ -235,9 +249,9 @@ public class DelegatedAdminServiceTests
repo.Setup(r => r.SetDelegatedAdminStatusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(true);
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Id(Upn));
await svc.SetStatusAsync(Upn, TenantA, Constants.DelegatedStatus.Revoked);
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Id(Upn));
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Exactly(2));
}
@@ -251,7 +265,7 @@ public class DelegatedAdminServiceTests
ReturnsGroups(repo, Assignment(GroupId1, Constants.DelegatedRoles.DelegatedReader));
GroupTenants(repo, GroupId1, TenantA, TenantB);
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.Equal(2, scope.TenantIds.Count);
Assert.True(scope.Covers(TenantA));
@@ -267,7 +281,7 @@ public class DelegatedAdminServiceTests
ReturnsGroups(repo, Assignment(GroupId1));
GroupTenants(repo, GroupId1, TenantB, TenantC);
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.Equal(3, scope.TenantIds.Count);
Assert.True(scope.Covers(TenantA));
@@ -282,7 +296,7 @@ public class DelegatedAdminServiceTests
ReturnsGroups(repo, Assignment(GroupId1, enabled: false));
GroupTenants(repo, GroupId1, TenantA);
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.IsEmpty);
Assert.False(scope.Covers(TenantA));
@@ -295,7 +309,7 @@ public class DelegatedAdminServiceTests
ReturnsGroups(repo, Assignment(GroupId1, role: "SuperRoot"));
GroupTenants(repo, GroupId1, TenantA);
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.IsEmpty);
}
@@ -307,7 +321,7 @@ public class DelegatedAdminServiceTests
ReturnsGroups(repo, Assignment(GroupId1));
GroupTenants(repo, GroupId1); // no tenants
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.IsEmpty);
}
@@ -320,7 +334,7 @@ public class DelegatedAdminServiceTests
GroupTenants(repo, GroupId1, TenantA);
GroupTenants(repo, GroupId2, TenantA, TenantB);
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.Equal(2, scope.TenantIds.Count);
Assert.True(scope.Covers(TenantA));
@@ -335,7 +349,7 @@ public class DelegatedAdminServiceTests
ReturnsGroups(repo, Assignment(GroupId1, Constants.DelegatedRoles.DelegatedAdmin));
GroupTenants(repo, GroupId1, TenantA);
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.CanWrite(TenantA)); // stronger DelegatedAdmin wins across sources
}
@@ -348,7 +362,7 @@ public class DelegatedAdminServiceTests
ReturnsGroups(repo, Assignment(GroupId1, Constants.DelegatedRoles.DelegatedReader));
GroupTenants(repo, GroupId1, TenantA);
var scope = await svc.GetScopeAsync(Upn);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.CanWrite(TenantA)); // direct DelegatedAdmin not downgraded by group reader
}
@@ -360,8 +374,8 @@ public class DelegatedAdminServiceTests
ReturnsGroups(repo, Assignment(GroupId1));
GroupTenants(repo, GroupId1, TenantA);
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Id(Upn));
await svc.GetScopeAsync(Id(Upn));
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
repo.Verify(r => r.GetGroupTenantsAsync(GroupId1), Times.Once);
@@ -380,9 +394,9 @@ public class DelegatedAdminServiceTests
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(true);
await svc.GetScopeAsync(Upn); // primes cache
await svc.AssignGroupAsync(Upn, GroupId2, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
await svc.GetScopeAsync(Upn); // must re-resolve after invalidation
await svc.GetScopeAsync(Id(Upn)); // primes cache
await svc.AssignGroupAsync(Upn, GroupId2, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example", HomeTenant, null);
await svc.GetScopeAsync(Id(Upn)); // must re-resolve after invalidation
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Exactly(2));
}
@@ -397,7 +411,7 @@ public class DelegatedAdminServiceTests
.ReturnsAsync(true);
var assigned = await svc.AssignGroupAsync(
Upn, GroupId1, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
Upn, GroupId1, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example", HomeTenant, null);
Assert.False(assigned);
repo.Verify(r => r.AssignGroupAsync(
@@ -412,9 +426,9 @@ public class DelegatedAdminServiceTests
GroupTenants(repo, GroupId1, TenantA);
repo.Setup(r => r.UnassignGroupAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Id(Upn));
var unassigned = await svc.UnassignGroupAsync(Upn, GroupId1);
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Id(Upn));
Assert.True(unassigned);
// Verify re-resolution via the tenant expansion (the unassign now also reads assignments itself,
@@ -459,11 +473,11 @@ public class DelegatedAdminServiceTests
new() { Upn = upnB, GroupId = GroupId1, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
});
await svc.GetScopeAsync(Upn); // primes A
await svc.GetScopeAsync(upnB); // primes B
await svc.GetScopeAsync(Id(Upn)); // primes A
await svc.GetScopeAsync(Id(upnB)); // primes B
var removed = await svc.RemoveTenantFromGroupAsync(GroupId1, TenantA);
await svc.GetScopeAsync(Upn); // re-resolves A
await svc.GetScopeAsync(upnB); // re-resolves B
await svc.GetScopeAsync(Id(Upn)); // re-resolves A
await svc.GetScopeAsync(Id(upnB)); // re-resolves B
Assert.True(removed);
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(Upn), Times.Exactly(2));
@@ -536,9 +550,9 @@ public class DelegatedAdminServiceTests
new() { Upn = Upn, GroupId = GroupId1, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
});
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Id(Upn));
await svc.DeleteGroupAsync(GroupId1);
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Id(Upn));
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(Upn), Times.Exactly(2));
}
@@ -555,7 +569,7 @@ public class DelegatedAdminServiceTests
.ReturnsAsync(true);
var assigned = await svc.AssignGroupAsync(
Upn, "TPL-LOWER", Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
Upn, "TPL-LOWER", Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example", HomeTenant, null);
Assert.True(assigned);
// Repo was looked up + written with the normalized (lowercase) id.
@@ -572,9 +586,9 @@ public class DelegatedAdminServiceTests
GroupTenants(repo, GroupId1, TenantA);
repo.Setup(r => r.RenameTenantGroupAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Id(Upn));
await svc.RenameGroupAsync(GroupId1, "Renamed");
await svc.GetScopeAsync(Upn); // name-only change → scope stays cached
await svc.GetScopeAsync(Id(Upn)); // name-only change → scope stays cached
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
}
@@ -0,0 +1,73 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The Collect Logs capability flip that feeds the DiagnosticsUploadEnabled/Disabled ops
/// events: only a change of "would an on-demand upload work" counts, never a plain edit.
/// </summary>
public class DiagnosticsUploadConfigChangeTests
{
private static TenantConfiguration Cfg(string? mode, string? destination = null, string? sas = null) => new()
{
TenantId = "t1",
DiagnosticsUploadMode = mode!,
DiagnosticsUploadDestination = destination!,
DiagnosticsBlobSasUrl = sas!,
};
[Theory]
[InlineData(null, null, null, false)]
[InlineData("Off", "Hosted", null, false)]
[InlineData("OnFailure", null, null, false)]
[InlineData("OnFailure", "Hosted", null, true)]
[InlineData("Always", "CustomerSas", "https://x.blob.core.windows.net/c?sig=1", true)]
[InlineData("Always", null, "https://x.blob.core.windows.net/c?sig=1", true)]
public void IsConfigured_mirrors_the_feature_flag_semantics(string? mode, string? dest, string? sas, bool expected)
{
Assert.Equal(expected, DiagnosticsUploadConfigChange.IsConfigured(Cfg(mode, dest, sas)));
Assert.False(DiagnosticsUploadConfigChange.IsConfigured(null));
}
[Fact]
public void Detect_quick_config_flip_reports_enabled_with_destination_and_mode()
{
// The Collect Logs quick-config dialog: Off/unset -> Hosted + OnFailure.
var change = DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("OnFailure", "Hosted"));
Assert.NotNull(change);
Assert.True(change!.Enabled);
Assert.Equal("Hosted", change.Destination);
Assert.Equal("OnFailure", change.Mode);
}
[Fact]
public void Detect_missing_before_row_counts_as_not_configured()
{
var change = DiagnosticsUploadConfigChange.Detect(null, Cfg("Always", "Hosted"));
Assert.True(change?.Enabled);
}
[Fact]
public void Detect_reports_disabled_when_mode_goes_off()
{
var change = DiagnosticsUploadConfigChange.Detect(Cfg("Always", "Hosted"), Cfg("Off", "Hosted"));
Assert.NotNull(change);
Assert.False(change!.Enabled);
Assert.Equal("Off", change.Mode);
}
[Fact]
public void Detect_ignores_edits_that_keep_the_capability_state()
{
// Mode change while staying on: no flip.
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("OnFailure", "Hosted"), Cfg("Always", "Hosted")));
// Destination not yet usable in both states: no flip.
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("OnFailure")));
// Unchanged.
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("Off")));
}
}
@@ -75,11 +75,14 @@ public sealed class EmailServiceTests
{
var (sut, handler, logger) = Build(apiKey: "");
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
var sent = await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
Assert.False(sent);
Assert.Equal(0, handler.CallCount);
// Warning, not Debug: an unconfigured provider is operator misconfiguration, and worker
// logs below Warning never reach Application Insights — Debug made it invisible.
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
e => e.Level == LogLevel.Warning && e.Message.Contains("Email:ApiKey not configured"));
}
// ----- wire format -----
@@ -394,14 +394,91 @@ public class EnrollmentTimeoutClassifierTests
}
[Fact]
public void Classify_agent_timeout_failed_session_classifies_honestly()
public void Classify_agent_timeout_terminalizes_immediately_instead_of_parking()
{
// WG Part-2 shape (session 1506ce9f): DeviceSetup 4/4, no user phase, watchdog fired at
// 6h — honest verdict is AwaitingUser (within grace), never Failed.
// The watchdog firing proves the agent is gone for good — an AwaitingUser park from this
// state is a countdown, not a wait: all 5 observed maxlife:r5_awaiting episodes expired
// unhealed (calibration read 2026-08-27). Still never Failed, and late straggler
// telemetry can heal the Incomplete (TryLateTelemetryReconcileAsync).
var timeout = Evt("enrollment_failed",
data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
var (status, _) = Classify(new[] { Esp(DeviceSetup44), timeout }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, status);
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), timeout }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Incomplete, status);
Assert.Contains("max-lifetime watchdog", reason);
// The V2 shutdown shape carries the same fact.
var shutdown = Evt("agent_shutting_down",
data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
var (viaShutdown, _) = Classify(new[] { Esp(DeviceSetup44), shutdown }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Incomplete, viaShutdown);
// WhiteGlove Part 2 (rule 1b) is deliberately untouched: a sealed/powered-off device
// between technician and end user stays AwaitingUser — that park DOES heal
// (the observed maxlife:r1b_awaiting episode resolved to Succeeded).
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), timeout });
var (wgStatus, _, wgRule) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, Start.AddHours(6), 72, isPreProvisioned: true, resumedAt: Start.AddHours(1));
Assert.Equal(SessionStatus.AwaitingUser, wgStatus);
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, wgRule);
}
// -------- Rule 5a: completed (assumed) — calibration read 2026-08-27 --------
[Fact]
public void ExtractRollup_detects_app_failures_and_maxlife_timeout()
{
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("app_install_failed") }).HasAppInstallFailure);
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).HasAgentMaxLifetimeTimeout);
var shutdown = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).HasAgentMaxLifetimeTimeout);
// Other shutdown reasons say nothing about max lifetime.
var ctrlC = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "ctrl_c" });
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { ctrlC }).HasAgentMaxLifetimeTimeout);
// A real failure event does not raise the app flag.
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_failed") }).HasAppInstallFailure);
}
[Fact]
public void Classify_desktop_without_hello_past_grace_is_assumed_complete()
{
// The healthy half of the r5 population (drill-down 2026-08-27): desktop reached, apps
// clean, agent died before the Hello terminal. Past the full grace "user phase still
// running" is no longer a possible explanation — completed (assumed), not a red Incomplete.
var (status, reason, rule) = ClassifyWithRule(new[]
{
Esp(DeviceSetup44), Esp(AccountSetup15), Evt("desktop_arrived"),
}, hoursSinceStart: 80, grace: 72);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Equal(ClassifierRules.R5DesktopAssumed, rule);
Assert.Contains("treating the enrollment as completed", reason);
Assert.Contains("Agent last reported", reason); // silence-transparency clause
}
[Fact]
public void Classify_desktop_with_app_failure_past_grace_stays_Incomplete()
{
// "Completed" must not overclaim a session whose ESP payload provably failed
// (drill-down session b1edc6f8: desktop reached but .NET 3.5 install failed).
var (status, _) = Classify(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("app_install_failed"),
}, hoursSinceStart: 80, grace: 72);
Assert.Equal(SessionStatus.Incomplete, status);
}
[Fact]
public void Classify_maxlife_with_desktop_and_clean_apps_is_assumed_complete_within_grace()
{
// Agent provably gone + desktop + clean apps: no reason to park — the assumption is as
// good now as it would be after the 51h grace.
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
var (status, _, rule) = ClassifyWithRule(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"), timeout,
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Equal(ClassifierRules.R5DesktopAssumed, rule);
}
[Fact]
@@ -0,0 +1,350 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Vulnerability;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
using static AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the EPSS integration and the NVD throttle contract:
/// FIRST's envelope parses with invariant-culture string numbers, a 429 opens a bounded cooldown
/// (Retry-After honoured but capped), the NVD→cache projection carries the CVSS vector, EPSS
/// application never erases an existing score, and the act/attend/track priority is exactly the
/// three documented rules.
/// </summary>
public class EpssAndNvdThrottleTests
{
// -----------------------------------------------------------------------
// EpssApiClient
// -----------------------------------------------------------------------
private const string SampleEnvelope = """
{"status":"OK","status-code":200,"version":"1.0","access":"public","total":2,"offset":0,"limit":100,
"data":[
{"cve":"CVE-2024-21447","epss":"0.00383","percentile":"0.73461","date":"2026-08-28"},
{"cve":"cve-2023-4863","epss":"0.94210","percentile":"0.99891","date":"2026-08-28"},
{"cve":"CVE-2020-0001","epss":"not-a-number","percentile":"0.5","date":"2026-08-28"},
{"cve":"","epss":"0.1","percentile":"0.5","date":"2026-08-28"}
]}
""";
[Fact]
public void ParseResponse_ParsesInvariantStrings_AndDropsUnparseableRows()
{
var scores = EpssApiClient.ParseResponse(SampleEnvelope);
Assert.Equal(2, scores.Count);
var a = scores.Single(s => s.CveId == "CVE-2024-21447");
Assert.Equal(0.00383, a.Score, 6);
Assert.Equal(0.73461, a.Percentile, 6);
Assert.Equal("2026-08-28", a.Date);
// Ids are normalised to upper case so cache lookups are stable.
Assert.Contains(scores, s => s.CveId == "CVE-2023-4863");
}
[Fact]
public void ParseResponse_EmptyOrMissingData_ReturnsEmpty()
{
Assert.Empty(EpssApiClient.ParseResponse("""{"status":"OK","data":[]}"""));
Assert.Empty(EpssApiClient.ParseResponse("""{"status":"OK"}"""));
}
private sealed class StubHandler : HttpMessageHandler
{
public List<Uri> Requests { get; } = new();
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Requests.Add(request.RequestUri!);
return Task.FromResult(_respond(request));
}
}
[Fact]
public async Task GetScoresAsync_BatchesByHundred_DedupesIds_AndSurvivesAFailedBatch()
{
int call = 0;
var handler = new StubHandler(req =>
{
call++;
if (call == 2)
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
// Echo the first id of the batch back as scored.
var query = Uri.UnescapeDataString(req.RequestUri!.Query);
var firstId = query.Substring("?cve=".Length).Split(',')[0];
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent($$"""{"status":"OK","data":[{"cve":"{{firstId}}","epss":"0.5","percentile":"0.9","date":"2026-08-28"}]}""")
};
});
var client = new EpssApiClient(new HttpClient(handler), NullLogger<EpssApiClient>.Instance);
// 150 distinct ids + 10 duplicates (case-varied) ⇒ exactly two requests.
var ids = Enumerable.Range(1, 150).Select(i => $"CVE-2024-{10000 + i}").ToList();
ids.AddRange(ids.Take(10).Select(id => id.ToLowerInvariant()));
var result = await client.GetScoresAsync(ids);
Assert.Equal(2, handler.Requests.Count);
Assert.Single(result); // batch 1 scored one id, batch 2 failed soft
Assert.True(result.ContainsKey("cve-2024-10001")); // case-insensitive map
Assert.All(handler.Requests, u => Assert.StartsWith(EpssApiClient.EpssUrl, u.ToString()));
}
// -----------------------------------------------------------------------
// NVD throttle cooldown
// -----------------------------------------------------------------------
private static readonly DateTime Now = new(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void ComputeCooldown_NoHeader_UsesDefault()
{
Assert.Equal(NvdApiClient.ThrottleCooldown, NvdApiClient.ComputeCooldown(null, Now));
}
[Fact]
public void ComputeCooldown_RetryAfterDelta_IsHonoured()
{
var header = new RetryConditionHeaderValue(TimeSpan.FromSeconds(45));
Assert.Equal(TimeSpan.FromSeconds(45), NvdApiClient.ComputeCooldown(header, Now));
}
[Fact]
public void ComputeCooldown_RetryAfterDate_IsRelativeToNow()
{
var header = new RetryConditionHeaderValue(new DateTimeOffset(Now.AddSeconds(20)));
Assert.Equal(TimeSpan.FromSeconds(20), NvdApiClient.ComputeCooldown(header, Now));
}
[Fact]
public void ComputeCooldown_IsCapped_SoAServerCannotParkUsForAnHour()
{
var header = new RetryConditionHeaderValue(TimeSpan.FromHours(1));
Assert.Equal(NvdApiClient.MaxRetryAfter, NvdApiClient.ComputeCooldown(header, Now));
}
[Fact]
public void ComputeCooldown_PastDateOrZero_FallsBackToDefault()
{
Assert.Equal(NvdApiClient.ThrottleCooldown,
NvdApiClient.ComputeCooldown(new RetryConditionHeaderValue(new DateTimeOffset(Now.AddSeconds(-5))), Now));
Assert.Equal(NvdApiClient.ThrottleCooldown,
NvdApiClient.ComputeCooldown(new RetryConditionHeaderValue(TimeSpan.Zero), Now));
}
[Fact]
public void NvdFetchResult_FailureShapes_CarryEmptyDataButNeverSucceeded()
{
var throttled = NvdFetchResult<NvdCveResponse>.ThrottledResult();
Assert.False(throttled.Succeeded);
Assert.True(throttled.Throttled);
Assert.NotNull(throttled.Data);
Assert.Empty(throttled.Data.Vulnerabilities);
var failed = NvdFetchResult<NvdCveResponse>.Failed();
Assert.False(failed.Succeeded);
Assert.False(failed.Throttled);
var ok = NvdFetchResult<NvdCveResponse>.Ok(new NvdCveResponse { TotalResults = 1 });
Assert.True(ok.Succeeded);
Assert.Equal(1, ok.Data.TotalResults);
}
// -----------------------------------------------------------------------
// NVD → CachedCve projection
// -----------------------------------------------------------------------
[Fact]
public void ProjectNvdResponse_CarriesVector_PrefersV31_AndKeepsOnlyVulnerableRanges()
{
var response = new NvdCveResponse
{
Vulnerabilities =
{
new NvdVulnerabilityItem
{
Cve = new NvdCve
{
Id = "CVE-2024-1",
Published = "2026-01-01T00:00:00.000",
Descriptions = { new NvdDescription { Lang = "en", Value = "desc" } },
Metrics = new NvdMetrics
{
CvssMetricV30 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 5, BaseSeverity = "MEDIUM", VectorString = "CVSS:3.0/AV:L" } } },
CvssMetricV31 = { new NvdCvssMetricV31 { CvssData = new NvdCvssData { BaseScore = 9.8, BaseSeverity = "CRITICAL", VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" } } },
},
Configurations =
{
new NvdConfiguration
{
Nodes =
{
new NvdNode
{
CpeMatch =
{
new NvdCpeMatch { Vulnerable = true, Criteria = "cpe:2.3:a:v:p:*:*:*:*:*:*:*:*", VersionEndExcluding = "2.0" },
new NvdCpeMatch { Vulnerable = false, Criteria = "cpe:2.3:o:microsoft:windows:*:*:*:*:*:*:*:*" },
}
}
}
}
}
}
},
new NvdVulnerabilityItem
{
Cve = new NvdCve { Id = "CVE-2024-2", Descriptions = { new NvdDescription { Lang = "en", Value = "no metrics" } } }
},
}
};
var cves = VulnerabilityCorrelationService.ProjectNvdResponse(response);
Assert.Equal(2, cves.Count);
var first = cves[0];
Assert.Equal(9.8, first.CvssScore);
Assert.Equal("CRITICAL", first.CvssSeverity);
Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", first.CvssVector);
Assert.Single(first.AffectedVersions);
Assert.Equal("2.0", first.AffectedVersions[0].VersionEndExcluding);
Assert.Null(first.EpssScore); // never defaulted before enrichment
var second = cves[1];
Assert.Equal(0, second.CvssScore);
Assert.Equal("UNKNOWN", second.CvssSeverity);
Assert.Null(second.CvssVector);
}
[Fact]
public void ProjectNvdResponse_Null_ReturnsEmpty()
{
Assert.Empty(VulnerabilityCorrelationService.ProjectNvdResponse(null));
}
// -----------------------------------------------------------------------
// EPSS application onto cached CVEs
// -----------------------------------------------------------------------
[Fact]
public void ApplyEpssScores_SetsNewScores_KeepsUnscoredUntouched_AndReportsOnlyChanges()
{
var cves = new List<CachedCve>
{
new() { CveId = "CVE-1", EpssScore = 0.2, EpssPercentile = 0.8, EpssDate = "2026-08-27" },
new() { CveId = "CVE-2" },
new() { CveId = "CVE-3", EpssScore = 0.5, EpssPercentile = 0.9, EpssDate = "2026-08-28" },
};
var scores = new Dictionary<string, EpssScore>(StringComparer.OrdinalIgnoreCase)
{
["cve-1"] = new EpssScore { CveId = "CVE-1", Score = 0.25, Percentile = 0.85, Date = "2026-08-28" }, // changed
["CVE-3"] = new EpssScore { CveId = "CVE-3", Score = 0.5, Percentile = 0.9, Date = "2026-08-28" }, // unchanged
// CVE-2 absent: FIRST did not score it (or its batch failed) ⇒ stays null
};
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, scores);
Assert.Equal(1, changed);
Assert.Equal(0.25, cves[0].EpssScore);
Assert.Equal("2026-08-28", cves[0].EpssDate);
Assert.Null(cves[1].EpssScore);
Assert.Equal(0.5, cves[2].EpssScore);
}
[Fact]
public void ApplyEpssScores_NeverErasesYesterdaysScore_WhenTodayHasNone()
{
var cves = new List<CachedCve> { new() { CveId = "CVE-1", EpssScore = 0.3, EpssPercentile = 0.7, EpssDate = "2026-08-27" } };
var changed = VulnerabilityCorrelationService.ApplyEpssScores(cves, new Dictionary<string, EpssScore>());
Assert.Equal(0, changed);
Assert.Equal(0.3, cves[0].EpssScore);
}
[Fact]
public void BuildCveCacheRow_PassesCachedAtThrough_SoEpssRewritesDoNotLookLikeNvdFetches()
{
var cachedAt = new DateTime(2026, 8, 20, 3, 0, 0, DateTimeKind.Utc);
var row = VulnerabilityCorrelationService.BuildCveCacheRow("cpe:2.3:a:v:p", new List<CachedCve> { new() { CveId = "CVE-1" } }, cachedAt);
Assert.Equal(cachedAt.ToString("o"), row["CachedAt"]);
Assert.Equal("cpe:2.3:a:v:p", row["CpeUri"]);
Assert.Equal(1, row["TotalCves"]);
Assert.True(row.ContainsKey("CveDataJson"));
}
[Fact]
public void CachedCve_RoundTrips_WithoutWritingNulls_SoLegacyRowsStayReadable()
{
var json = System.Text.Json.JsonSerializer.Serialize(new List<CachedCve> { new() { CveId = "CVE-1", CvssScore = 7 } }, JsonOptions);
Assert.DoesNotContain("EpssScore", json);
Assert.DoesNotContain("CvssVector", json);
// A row written before this change (no EPSS/vector properties at all) still deserialises.
var legacy = """[{"CveId":"CVE-9","CvssScore":4.3,"CvssSeverity":"MEDIUM","Description":"","PublishedDate":"","AffectedVersions":[]}]""";
var cves = System.Text.Json.JsonSerializer.Deserialize<List<CachedCve>>(legacy, JsonOptions)!;
Assert.Null(cves[0].EpssScore);
Assert.Null(cves[0].CvssVector);
}
// -----------------------------------------------------------------------
// Priority
// -----------------------------------------------------------------------
[Theory]
[InlineData(true, null, 0.0, CvePriority.Act)]
[InlineData(true, 0.001, 2.0, CvePriority.Act)]
[InlineData(false, 0.1, 2.0, CvePriority.Attend)]
[InlineData(false, 0.0999, 2.0, CvePriority.Track)]
[InlineData(false, null, 9.0, CvePriority.Attend)]
[InlineData(false, 0.0, 9.8, CvePriority.Attend)]
[InlineData(false, null, 8.9, CvePriority.Track)]
[InlineData(false, 0.05, 7.5, CvePriority.Track)]
public void Compute_FollowsTheThreeRules(bool kev, double? epss, double cvss, string expected)
{
Assert.Equal(expected, CvePriority.Compute(kev, epss, cvss));
}
[Fact]
public void Max_PicksHigherBand_AndTreatsUnknownAsLowest()
{
Assert.Equal(CvePriority.Act, CvePriority.Max(CvePriority.Track, CvePriority.Act));
Assert.Equal(CvePriority.Attend, CvePriority.Max(CvePriority.Attend, null));
Assert.Equal(CvePriority.Track, CvePriority.Max(null, ""));
}
[Fact]
public void StampFindingPriority_RollsUpHighestBand_AndMaxEpss()
{
var finding = new Dictionary<string, object> { ["maxEpssScore"] = 0.9 /* stale value, must be recomputed */ };
var vulns = new List<object>
{
new Dictionary<string, object> { ["priority"] = CvePriority.Track, ["epssScore"] = 0.02 },
new Dictionary<string, object> { ["priority"] = CvePriority.Attend, ["epssScore"] = 0.4 },
new Dictionary<string, object> { ["priority"] = CvePriority.Track },
};
VulnerabilityCorrelationService.StampFindingPriority(finding, vulns);
Assert.Equal(CvePriority.Attend, finding["priority"]);
Assert.Equal(0.4, finding["maxEpssScore"]);
}
[Fact]
public void StampFindingPriority_NoScoredCves_OmitsMaxEpss()
{
var finding = new Dictionary<string, object>();
VulnerabilityCorrelationService.StampFindingPriority(finding, new List<object> { new Dictionary<string, object> { ["priority"] = CvePriority.Track } });
Assert.Equal(CvePriority.Track, finding["priority"]);
Assert.False(finding.ContainsKey("maxEpssScore"));
}
}
@@ -124,7 +124,7 @@ public class GetImeVersionHistoryRedactionTests
new[]
{
"firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt",
"msiArchiveBlobPath", "msiArchiveStatus", "msiBytes", "msiSha256", "msiSourceUrl",
"msiArchiveBlobPath", "msiArchiveStatus", "msiArchiveUpdatedAt", "msiBytes", "msiSha256", "msiSourceUrl",
"sessionCount", "version",
},
keys);
@@ -211,6 +211,7 @@ public class GetImeVersionHistoryRedactionTests
LastSeenAt = Last,
SessionCount = 3109,
MsiArchiveStatus = "Archived",
MsiArchiveUpdatedAt = Last,
MsiArchiveBlobPath = $"{version}/IntuneWindowsAgent.msi",
MsiSha256 = "4d6b85fd3c49cf227ab4711308bca1f3c701cad9bff1799da4c6b6616066bb5e",
MsiBytes = 13_107_200,
@@ -36,14 +36,14 @@ public class ImeMsiArchiveQueueWorkerTests
var harness = new Harness();
harness.Archiver.Result = new ImeMsiArchiveResult(
true, ImeMsiArchiver.Statuses.Archived, Retryable: false,
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.CanonicalMsiUrl);
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]);
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
Version, ImeMsiArchiver.Statuses.Archived,
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.CanonicalMsiUrl),
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]),
Times.Once);
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
@@ -56,7 +56,7 @@ public class ImeMsiArchiveQueueWorkerTests
var harness = new Harness();
harness.Archiver.Result = new ImeMsiArchiveResult(
false, ImeMsiArchiver.Statuses.FailedDownload, Retryable: true,
null, null, null, ImeMsiArchiver.CanonicalMsiUrl);
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]);
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
await harness.RunUntilAsync(() => harness.Archiver.Calls > 0);
@@ -64,7 +64,7 @@ public class ImeMsiArchiveQueueWorkerTests
// Row always tells the truth — the failure status IS merged...
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
Version, ImeMsiArchiver.Statuses.FailedDownload,
null, null, null, ImeMsiArchiver.CanonicalMsiUrl),
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]),
Times.AtLeastOnce);
// ...but the message stays for the visibility-timeout retry → poison ladder.
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
@@ -78,14 +78,14 @@ public class ImeMsiArchiveQueueWorkerTests
var harness = new Harness();
harness.Archiver.Result = new ImeMsiArchiveResult(
false, ImeMsiArchiver.Statuses.FailedTooLarge, Retryable: false,
null, null, 999L, ImeMsiArchiver.CanonicalMsiUrl);
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]);
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
Version, ImeMsiArchiver.Statuses.FailedTooLarge,
null, null, 999L, ImeMsiArchiver.CanonicalMsiUrl),
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]),
Times.Once);
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
@@ -131,7 +131,7 @@ public class ImeMsiArchiveQueueWorkerTests
private static ImeMsiArchiveEnvelope Envelope() => new()
{
Version = Version,
MsiDownloadUrl = ImeMsiArchiver.CanonicalMsiUrl,
MsiDownloadUrl = ImeMsiArchiver.FallbackMsiUrls[1],
MsiMatchedBy = "productVersion",
TenantId = "11111111-1111-1111-1111-111111111111",
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
@@ -15,15 +16,21 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The IME-installer archiver downloads a newly sighted IME build into the permanent
/// ime-archive container. Its contract: NEVER throw (worker decides retry from the result),
/// treat the agent-supplied URL as untrusted (allowlist, else canonical fallback), enforce
/// the size cap both at Content-Length preflight and mid-stream, and stay idempotent under
/// queue re-delivery (409 on the write-once upload == archived). Tests pin every status
/// branch via a recording subclass — no Azurite, no HTTP.
/// treat the agent-supplied URL as untrusted (allowlist, else distribution hosts only),
/// enforce the size cap both at Content-Length preflight and mid-stream, stay idempotent
/// under queue re-delivery (409 on the write-once upload == archived) — and, since
/// 2026-08-29, NEVER file bytes under a version they are not: every candidate download's
/// ProductVersion is checked against the observed version and the hosts are walked in
/// order until one matches. Tests pin every status branch via a recording subclass — no
/// Azurite, no HTTP, no disk.
/// </summary>
public class ImeMsiArchiverTests
{
private const string Version = "1.104.102.0";
private const string EventUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
private const string PrimaryUrl = "https://imeswda-afd-primary.manage.microsoft.com/IntuneWindowsAgent.msi";
private const string SecondaryUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
private const string HotfixUrl = "https://imeswda-afd-hotfix.manage.microsoft.com/IntuneWindowsAgent.msi";
// =========================================================================
// URL allowlist (static — the SSRF boundary)
@@ -49,6 +56,83 @@ public class ImeMsiArchiverTests
Assert.Equal(expected, ImeMsiArchiver.IsAllowedMsiUrl(url));
}
// =========================================================================
// Candidate order — event URL first, then every distribution host, no duplicates
// =========================================================================
[Fact]
public void BuildCandidateUrls_EventUrlFirst_ThenHosts_Deduplicated()
{
var custom = "https://swda01-mscdn.manage.microsoft.com/IntuneWindowsAgent.msi";
Assert.Equal(new[] { custom, PrimaryUrl, SecondaryUrl, HotfixUrl }, ImeMsiArchiver.BuildCandidateUrls(custom));
// Event URL equal to a host (any casing) is not tried twice.
Assert.Equal(new[] { SecondaryUrl.ToUpperInvariant(), PrimaryUrl, HotfixUrl },
ImeMsiArchiver.BuildCandidateUrls(SecondaryUrl.ToUpperInvariant()));
Assert.Equal(new[] { PrimaryUrl, SecondaryUrl, HotfixUrl }, ImeMsiArchiver.BuildCandidateUrls(null));
Assert.Equal(new[] { PrimaryUrl, SecondaryUrl, HotfixUrl },
ImeMsiArchiver.BuildCandidateUrls("https://evil.example.com/IntuneWindowsAgent.msi"));
}
[Theory]
[InlineData("1.105.103.0", "1.105.103.0", true)]
[InlineData("1.105.103.0", "1.105.103", true)] // trailing zero dropped by the MSI
[InlineData("1.105.103", "1.105.103.0", true)]
[InlineData(" 1.105.103.0 ", "1.105.103.0", true)]
[InlineData("1.105.103.0", "1.104.102.0", false)]
[InlineData("1.105.103.0", null, false)] // unreadable package never matches
[InlineData("1.105.103.0", "", false)]
[InlineData("1.105.103.0", "latest", false)]
public void VersionsMatch_cases(string observed, string? productVersion, bool expected)
{
Assert.Equal(expected, ImeMsiArchiver.VersionsMatch(observed, productVersion));
}
// =========================================================================
// Re-queue rule for later sightings of a known version
// =========================================================================
public static IEnumerable<object?[]> RequeueCases()
{
var now = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc);
var fresh = now.AddHours(-1);
var stale = now.AddHours(-25);
static object?[] Case(bool isNew, string? status, DateTime? updatedAt, string? url, string? matchedBy, bool expected) =>
new object?[] { isNew, status, updatedAt, url, matchedBy, expected };
yield return Case(false, "Failed:VersionMismatch", stale, HotfixUrl, "productVersion", true);
yield return Case(false, "Failed:VersionMismatch", null, HotfixUrl, "productVersion", true); // never stamped
yield return Case(false, null, null, HotfixUrl, "productVersion", true); // predates archiver / manual backfill
yield return Case(false, "Queued", stale, HotfixUrl, "productVersion", true); // lost message, backoff elapsed
yield return Case(false, "Failed:Download", stale, HotfixUrl, "productVersion", true); // poisoned after retries
yield return Case(false, "Failed:TooLarge", stale, HotfixUrl, "productVersion", true); // cap may have changed
yield return Case(false, "Failed:VersionMismatch", fresh, HotfixUrl, "productVersion", false); // backoff
yield return Case(false, "Queued", fresh, HotfixUrl, "productVersion", false); // already in flight
yield return Case(false, "Archived", stale, HotfixUrl, "productVersion", false);
yield return Case(false, "Failed:BadVersion", stale, HotfixUrl, "productVersion", false); // permanent
yield return Case(false, "Failed:VersionMismatch", stale, HotfixUrl, "fileName", false); // URL not authoritative
yield return Case(false, "Failed:VersionMismatch", stale, HotfixUrl, null, false);
yield return Case(false, "Failed:VersionMismatch", stale, null, "productVersion", false); // no URL
yield return Case(false, "Failed:VersionMismatch", stale, "https://evil.example.com/IntuneWindowsAgent.msi", "productVersion", false);
yield return Case(true, null, null, HotfixUrl, "productVersion", false); // first sighting has its own path
}
[Theory]
[MemberData(nameof(RequeueCases))]
public void ShouldRequeueOnSighting_cases(bool isNew, string? status, DateTime? updatedAt, string? url, string? matchedBy, bool expected)
{
var now = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc);
var sighting = new ImeVersionSighting { IsNew = isNew, MsiArchiveStatus = status, MsiArchiveUpdatedAt = updatedAt };
Assert.Equal(expected, ImeMsiArchiver.ShouldRequeueOnSighting(sighting, url, matchedBy, now));
}
[Fact]
public void ShouldRequeueOnSighting_NullSighting_False()
{
Assert.False(ImeMsiArchiver.ShouldRequeueOnSighting(null, HotfixUrl, "productVersion", DateTime.UtcNow));
}
// =========================================================================
// Version guard (blob path is built from untrusted input)
// =========================================================================
@@ -72,33 +156,131 @@ public class ImeMsiArchiverTests
}
// =========================================================================
// URL selection
// URL selection + version verification
// =========================================================================
[Fact]
public async Task ArchiveAsync_AllowedEventUrl_IsUsed()
public async Task ArchiveAsync_AllowedEventUrl_ServingObservedVersion_IsUsedFirst()
{
var archiver = new RecordingArchiver();
var result = await archiver.ArchiveAsync(Envelope(url: EventUrl));
Assert.True(result.Success);
Assert.Equal(EventUrl, archiver.DownloadLastUrl);
Assert.Equal(1, archiver.DownloadCalls);
Assert.Equal(EventUrl, archiver.DownloadUrls.Single());
Assert.Equal(EventUrl, result.SourceUrl);
}
[Theory]
[InlineData("https://evil.example.com/IntuneWindowsAgent.msi")] // allowlist reject
[InlineData(null)] // pre-enrichment agent
public async Task ArchiveAsync_MissingOrRejectedEventUrl_FallsBackToCanonical(string? url)
public async Task ArchiveAsync_MissingOrRejectedEventUrl_StartsWithPrimaryHost(string? url)
{
var archiver = new RecordingArchiver();
var result = await archiver.ArchiveAsync(Envelope(url: url));
Assert.True(result.Success);
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, archiver.DownloadLastUrl);
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, result.SourceUrl);
Assert.Equal(PrimaryUrl, archiver.DownloadUrls.Single());
Assert.Equal(PrimaryUrl, result.SourceUrl);
}
/// <summary>
/// THE 2026-08-29 incident: the event carried no URL, the secondary host still served
/// the previous build and it got archived under the new version. Now the wrong build is
/// detected and the walk continues to the host that actually serves the version.
/// </summary>
[Fact]
public async Task ArchiveAsync_HostsServeOtherBuilds_WalksUntilProductVersionMatches()
{
var archiver = new RecordingArchiver
{
ProductVersionByUrl =
{
[PrimaryUrl] = "1.105.101.0",
[SecondaryUrl] = "1.104.102.0",
[HotfixUrl] = "1.105.103.0",
},
};
var result = await archiver.ArchiveAsync(Envelope(version: "1.105.103.0", url: null));
Assert.True(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
Assert.Equal(new[] { PrimaryUrl, SecondaryUrl, HotfixUrl }, archiver.DownloadUrls);
Assert.Equal(HotfixUrl, result.SourceUrl);
Assert.Equal("1.105.103.0/IntuneWindowsAgent.msi", result.BlobPath);
Assert.Equal(1, archiver.UploadCalls);
// Provenance records the verified version and the full walk.
Assert.Contains("\"productVersion\": \"1.105.103.0\"", archiver.ProvenanceLastJson);
Assert.Contains("version-mismatch", archiver.ProvenanceLastJson);
Assert.Contains("1.105.101.0", archiver.ProvenanceLastJson);
Assert.Contains("\"urlFromEvent\": false", archiver.ProvenanceLastJson);
}
[Fact]
public async Task ArchiveAsync_EventUrlServesWrongBuild_FallsThroughToHosts()
{
var custom = "https://swda01-mscdn.manage.microsoft.com/IntuneWindowsAgent.msi";
var archiver = new RecordingArchiver
{
ProductVersionByUrl = { [custom] = "1.103.101.0", [PrimaryUrl] = Version },
};
var result = await archiver.ArchiveAsync(Envelope(url: custom));
Assert.True(result.Success);
Assert.Equal(new[] { custom, PrimaryUrl }, archiver.DownloadUrls);
Assert.Equal(PrimaryUrl, result.SourceUrl);
}
[Fact]
public async Task ArchiveAsync_NoHostServesVersion_VersionMismatch_NotRetryable_NoUpload()
{
var archiver = new RecordingArchiver { DefaultProductVersion = "1.104.102.0" };
var result = await archiver.ArchiveAsync(Envelope(version: "1.105.103.0", url: EventUrl));
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedVersionMismatch, result.Status);
Assert.False(result.Retryable);
Assert.Null(result.SourceUrl);
Assert.Equal(3, archiver.DownloadCalls); // event URL == secondary → deduped
Assert.Equal(0, archiver.UploadCalls);
Assert.Equal(0, archiver.ProvenanceCalls);
}
[Fact]
public async Task ArchiveAsync_UnreadablePackage_TreatedAsMismatch()
{
var archiver = new RecordingArchiver { DefaultProductVersion = null };
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedVersionMismatch, result.Status);
Assert.Equal(0, archiver.UploadCalls);
}
[Fact]
public async Task ArchiveAsync_UploadReceivesTheVerifiedBytes()
{
var right = new byte[4096];
new Random(1).NextBytes(right);
var wrong = new byte[4096];
new Random(2).NextBytes(wrong);
var archiver = new RecordingArchiver
{
PayloadByUrl = { [PrimaryUrl] = wrong, [SecondaryUrl] = right },
ProductVersionByUrl = { [PrimaryUrl] = "0.0.0.1", [SecondaryUrl] = Version },
};
var result = await archiver.ArchiveAsync(Envelope(url: null));
Assert.True(result.Success);
Assert.Equal(Convert.ToHexString(SHA256.HashData(right)).ToLowerInvariant(), result.Sha256);
Assert.Equal(right, archiver.UploadedBytes);
}
// =========================================================================
@@ -127,14 +309,15 @@ public class ImeMsiArchiverTests
Assert.Equal($"{Version}/provenance.json", archiver.ProvenanceLastPath);
Assert.Contains(expectedSha, archiver.ProvenanceLastJson);
Assert.Contains(Version, archiver.ProvenanceLastJson);
Assert.Contains("\"urlFromEvent\": true", archiver.ProvenanceLastJson);
}
// =========================================================================
// Size cap — preflight and mid-stream
// Size cap — preflight and mid-stream (applies per candidate)
// =========================================================================
[Fact]
public async Task ArchiveAsync_ContentLengthAboveCap_Rejected_NoUpload()
public async Task ArchiveAsync_ContentLengthAboveCap_OnEveryHost_Rejected_NoUpload()
{
var archiver = new RecordingArchiver
{
@@ -170,6 +353,22 @@ public class ImeMsiArchiverTests
Assert.Equal(0, archiver.ProvenanceCalls);
}
[Fact]
public async Task ArchiveAsync_OversizedFirstHost_SmallerMatchingHostStillArchives()
{
var archiver = new RecordingArchiver
{
MaxDownloadSizeMB = 1,
PayloadByUrl = { [PrimaryUrl] = new byte[2 * 1024 * 1024], [SecondaryUrl] = new byte[2048] },
};
var result = await archiver.ArchiveAsync(Envelope(url: null));
Assert.True(result.Success);
Assert.Equal(SecondaryUrl, result.SourceUrl);
Assert.Contains("too-large", archiver.ProvenanceLastJson);
}
[Fact]
public async Task ArchiveAsync_CapZero_MeansUnlimited()
{
@@ -301,11 +500,11 @@ public class ImeMsiArchiverTests
}
// =========================================================================
// Transient failures — retryable, never thrown
// Transient failures — retryable, never thrown; one bad host does not end the walk
// =========================================================================
[Fact]
public async Task ArchiveAsync_DownloadFails_RetryableDownloadStatus()
public async Task ArchiveAsync_AllHostsFailToDownload_RetryableDownloadStatus()
{
var archiver = new RecordingArchiver
{
@@ -314,6 +513,40 @@ public class ImeMsiArchiverTests
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedDownload, result.Status);
Assert.True(result.Retryable);
Assert.Equal(3, archiver.DownloadCalls);
}
[Fact]
public async Task ArchiveAsync_FirstHostDown_NextHostArchives()
{
var archiver = new RecordingArchiver
{
DownloadExceptionByUrl = { [PrimaryUrl] = new HttpRequestException("503") },
};
var result = await archiver.ArchiveAsync(Envelope(url: null));
Assert.True(result.Success);
Assert.Equal(SecondaryUrl, result.SourceUrl);
Assert.Contains("download-failed", archiver.ProvenanceLastJson);
}
[Fact]
public async Task ArchiveAsync_TransientFailurePlusMismatch_ReportsTransient_SoTheQueueRetries()
{
// One host down, the rest serving another build: the down host might have had the
// version — worth the queue's retry ladder rather than a permanent mismatch.
var archiver = new RecordingArchiver
{
DefaultProductVersion = "0.0.0.1",
DownloadExceptionByUrl = { [HotfixUrl] = new HttpRequestException("503") },
};
var result = await archiver.ArchiveAsync(Envelope(url: null));
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedDownload, result.Status);
Assert.True(result.Retryable);
@@ -380,47 +613,68 @@ public class ImeMsiArchiverTests
private sealed class RecordingArchiver : ImeMsiArchiver
{
public byte[] Payload { get; set; } = new byte[2048];
public Dictionary<string, byte[]> PayloadByUrl { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>ProductVersion the fake reader reports for a URL's bytes; falls back to <see cref="DefaultProductVersion"/>.</summary>
public Dictionary<string, string?> ProductVersionByUrl { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Default = the envelope version under test, i.e. every host serves the right build unless told otherwise.</summary>
public string? DefaultProductVersion { get; set; } = Version;
public bool ReportContentLength { get; set; } = true;
public int MaxDownloadSizeMB { get; set; } = 250;
public Exception? DownloadException { get; set; }
public Dictionary<string, Exception> DownloadExceptionByUrl { get; } = new(StringComparer.OrdinalIgnoreCase);
public Exception? UploadException { get; set; }
public Exception? ProvenanceException { get; set; }
public bool ProvenanceExists { get; set; } = true;
public byte[]? ArchivedPayload { get; set; }
public Exception? ArchivedOpenException { get; set; }
public int DownloadCalls { get; private set; }
public string? DownloadLastUrl { get; private set; }
public int DownloadCalls => DownloadUrls.Count;
public List<string> DownloadUrls { get; } = new();
public int UploadCalls { get; private set; }
public string? UploadLastPath { get; private set; }
public long UploadedByteCount { get; private set; }
public byte[] UploadedBytes { get; private set; } = Array.Empty<byte>();
public int ProvenanceCalls { get; private set; }
public string? ProvenanceLastPath { get; private set; }
public string ProvenanceLastJson { get; private set; } = string.Empty;
private string? _currentUrl;
protected override Task<AdminConfiguration> GetAdminConfigurationAsync()
=> Task.FromResult(new AdminConfiguration { MaxImeMsiDownloadSizeMB = MaxDownloadSizeMB });
protected override Task<(Stream Content, long ContentLength)> OpenDownloadAsync(
string url, CancellationToken cancellationToken)
{
DownloadCalls++;
DownloadLastUrl = url;
DownloadUrls.Add(url);
_currentUrl = url;
if (DownloadExceptionByUrl.TryGetValue(url, out var perUrl)) throw perUrl;
if (DownloadException is not null) throw DownloadException;
var payload = PayloadByUrl.TryGetValue(url, out var p) ? p : Payload;
return Task.FromResult(
((Stream)new MemoryStream(Payload), ReportContentLength ? Payload.LongLength : -1L));
((Stream)new MemoryStream(payload), ReportContentLength ? payload.LongLength : -1L));
}
protected override Stream CreateSpool() => new MemoryStream();
protected override string? ReadProductVersion(Stream msi)
{
// The bytes are opaque test payloads — resolve "what version is this" by origin.
Assert.True(msi.CanSeek);
Assert.Equal(0, msi.Position);
return _currentUrl is not null && ProductVersionByUrl.TryGetValue(_currentUrl, out var v)
? v
: DefaultProductVersion;
}
protected override async Task UploadMsiAsync(string blobPath, Stream content, CancellationToken cancellationToken)
{
UploadCalls++;
UploadLastPath = blobPath;
// Drain the stream like the real blob upload would — this is what drives the
// hashing/cap wrapper (and mid-stream aborts surface here as exceptions).
var buffer = new byte[64 * 1024];
int read;
while ((read = await content.ReadAsync(buffer, cancellationToken)) > 0)
UploadedByteCount += read;
using var ms = new MemoryStream();
await content.CopyToAsync(ms, cancellationToken);
UploadedBytes = ms.ToArray();
UploadedByteCount += UploadedBytes.Length;
if (UploadException is not null) throw UploadException;
}
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -20,6 +21,7 @@ public class McpUserServiceDelegatedTests
private const string DelegatedUpn = "msp@contoso.com";
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
private readonly Mock<GlobalAdminService> _globalAdmin;
private readonly Mock<DelegatedAdminService> _delegatedAdmin;
@@ -32,18 +34,20 @@ public class McpUserServiceDelegatedTests
var cache = new MemoryCache(new MemoryCacheOptions());
_adminRepo = new Mock<IAdminRepository>();
var bindings = new StubAdminIdentityBindingService(bound: true);
_globalAdmin = new Mock<GlobalAdminService>(
_adminRepo.Object, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
_adminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
_delegatedAdmin = new Mock<DelegatedAdminService>(
_adminRepo.Object,
bindings,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
_adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
// Defaults: no platform role, no delegated scope, WhitelistOnly, no McpUsers row (NotPresent).
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>())).ReturnsAsync(DelegatedScope.Empty);
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
SetPolicy(McpAccessPolicy.WhitelistOnly);
@@ -61,7 +65,7 @@ public class McpUserServiceDelegatedTests
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (tenant, role) in assignments)
map[tenant.ToLowerInvariant()] = role;
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>()))
.ReturnsAsync(new DelegatedScope(map));
}
@@ -71,7 +75,7 @@ public class McpUserServiceDelegatedTests
SetDelegated((TenantA, Constants.DelegatedRoles.DelegatedReader),
(TenantB, Constants.DelegatedRoles.DelegatedReader));
var result = await _sut.IsAllowedAsync(DelegatedUpn);
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
Assert.True(result.IsAllowed);
Assert.False(result.IsGlobalAdmin);
@@ -93,7 +97,7 @@ public class McpUserServiceDelegatedTests
SetDelegated((TenantA, Constants.DelegatedRoles.DelegatedReader),
(TenantB, Constants.DelegatedRoles.DelegatedAdmin));
var result = await _sut.IsAllowedAsync(DelegatedUpn);
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
Assert.True(result.IsAllowed);
Assert.Equal(Constants.DelegatedRoles.DelegatedAdmin, result.DelegatedRole);
@@ -105,7 +109,7 @@ public class McpUserServiceDelegatedTests
SetPolicy(McpAccessPolicy.Disabled);
SetDelegated((TenantA, Constants.DelegatedRoles.DelegatedReader));
var result = await _sut.IsAllowedAsync(DelegatedUpn);
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
Assert.False(result.IsAllowed);
}
@@ -113,11 +117,11 @@ public class McpUserServiceDelegatedTests
[Fact]
public async Task GlobalAdmin_WhoIsAlsoDelegated_ReportsBothPlatformAndDelegatedScope()
{
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<string>()))
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()))
.ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
SetDelegated((TenantA, Constants.DelegatedRoles.DelegatedReader));
var result = await _sut.IsAllowedAsync(DelegatedUpn);
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
Assert.True(result.IsAllowed);
Assert.True(result.IsGlobalAdmin);
@@ -129,7 +133,7 @@ public class McpUserServiceDelegatedTests
[Fact]
public async Task NoScope_NotWhitelisted_UnderWhitelistOnly_IsDenied_WithNoDelegatedInfo()
{
var result = await _sut.IsAllowedAsync(DelegatedUpn);
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
Assert.False(result.IsAllowed);
Assert.Null(result.DelegatedTenantIds);
@@ -142,7 +146,7 @@ public class McpUserServiceDelegatedTests
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>()))
.ReturnsAsync(new McpUserEntry { Upn = DelegatedUpn, IsEnabled = true });
var result = await _sut.IsAllowedAsync(DelegatedUpn);
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
Assert.True(result.IsAllowed);
Assert.Equal("McpUser", result.AccessGrant);
@@ -160,7 +164,7 @@ public class McpUserServiceDelegatedTests
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>()))
.ReturnsAsync(new McpUserEntry { Upn = DelegatedUpn, IsEnabled = false });
var result = await _sut.IsAllowedAsync(DelegatedUpn);
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
Assert.False(result.IsAllowed);
}
@@ -174,7 +178,7 @@ public class McpUserServiceDelegatedTests
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>()))
.ReturnsAsync(new McpUserEntry { Upn = DelegatedUpn, IsEnabled = false });
var result = await _sut.IsAllowedAsync(DelegatedUpn);
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
Assert.False(result.IsAllowed);
}
@@ -186,7 +190,7 @@ public class McpUserServiceDelegatedTests
// still be granted — only an explicit Disabled row denies.
SetPolicy(McpAccessPolicy.AllMembers);
var result = await _sut.IsAllowedAsync(DelegatedUpn);
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
Assert.True(result.IsAllowed);
Assert.Equal("AllMembers", result.AccessGrant);
@@ -197,12 +201,12 @@ public class McpUserServiceDelegatedTests
{
// The Disabled row wins over EVERY grant path, including a platform role — the kill-switch
// is absolute, so an operator never has to reason about which grant path a user holds.
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<string>()))
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()))
.ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>()))
.ReturnsAsync(new McpUserEntry { Upn = DelegatedUpn, IsEnabled = false });
var result = await _sut.IsAllowedAsync(DelegatedUpn);
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
Assert.False(result.IsAllowed);
}
@@ -0,0 +1,430 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AutopilotMonitor.Functions.Services.Ime;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The MSI product-version reader is what stops the IME archiver from filing the wrong
/// build under a version folder (2026-08-29: Microsoft's versionless CDN hosts serve
/// different rings, so "new version == what the canonical URL serves" was false). These
/// tests build minimal OLE compound files in memory — mini-stream and regular-sector
/// layouts, long (>64 KB) pool strings, deleted-string holes, 3-byte string refs — and
/// pin the failure contract (never throw, null on anything unreadable). The local sweep
/// over real IME packages is the oracle for the string-pool id convention.
/// </summary>
public class MsiProductVersionReaderTests
{
// =========================================================================
// Stream-name decoding
// =========================================================================
[Theory]
[InlineData("!_StringPool")]
[InlineData("!_StringData")]
[InlineData("!Property")]
[InlineData("!ActionText")]
[InlineData("Binary.x")]
public void DecodeStreamName_RoundTripsEncoder(string name)
{
Assert.Equal(name, MsiProductVersionReader.DecodeStreamName(MsiBuilder.EncodeName(name)));
}
[Fact]
public void DecodeStreamName_LeavesPlainNamesAlone()
{
Assert.Equal("SummaryInformation", MsiProductVersionReader.DecodeStreamName("SummaryInformation"));
}
// =========================================================================
// Synthetic packages
// =========================================================================
[Fact]
public void MiniStreamPackage_ReadsProductVersion()
{
var msi = MsiBuilder.Build(new Dictionary<string, string>
{
["ProductName"] = "Microsoft Intune Management Extension",
["ProductVersion"] = "1.105.103.0",
["Manufacturer"] = "Microsoft Corporation",
});
Assert.Equal("1.105.103.0", MsiProductVersionReader.TryReadProductVersion(msi));
}
[Fact]
public void RegularSectorPackage_WithLongStringAndHoleBeforeVersion_KeepsIdsAligned()
{
// _StringData grows past the 4 KB mini-stream cutoff AND the pool contains a >64 KB
// string (two pool entries, one id) plus a deleted-string hole before ProductVersion —
// every later id must still resolve to the right text.
var msi = MsiBuilder.Build(
new Dictionary<string, string>
{
["ProductVersion"] = "1.104.102.0",
["ProductName"] = "IME",
},
extraStringsBeforeProperties: new[] { new string('x', 70_000), MsiBuilder.Hole, "filler" });
var table = MsiProductVersionReader.ReadPropertyTable(msi);
Assert.NotNull(table);
Assert.Equal("1.104.102.0", table!["ProductVersion"]);
Assert.Equal("IME", table["ProductName"]);
}
[Fact]
public void ThreeByteStringRefs_AreHonoured()
{
var msi = MsiBuilder.Build(
new Dictionary<string, string> { ["ProductVersion"] = "1.99.101.0" },
threeByteRefs: true);
Assert.Equal("1.99.101.0", MsiProductVersionReader.TryReadProductVersion(msi));
}
[Fact]
public void MissingProductVersionProperty_ReturnsNull()
{
var msi = MsiBuilder.Build(new Dictionary<string, string> { ["ProductName"] = "IME" });
Assert.Null(MsiProductVersionReader.TryReadProductVersion(msi));
}
[Fact]
public void MissingPropertyStream_ReturnsNull()
{
var msi = MsiBuilder.Build(new Dictionary<string, string> { ["ProductVersion"] = "1.0.0.0" }, omitPropertyTable: true);
Assert.Null(MsiProductVersionReader.TryReadProductVersion(msi));
}
[Fact]
public void NotACompoundFile_ReturnsNull()
{
var random = new byte[64 * 1024];
new Random(42).NextBytes(random);
Assert.Null(MsiProductVersionReader.TryReadProductVersion(new MemoryStream(random)));
Assert.Null(MsiProductVersionReader.TryReadProductVersion(new MemoryStream(Array.Empty<byte>())));
Assert.Null(MsiProductVersionReader.TryReadProductVersion(new MemoryStream(Encoding.ASCII.GetBytes("MZ not an msi"))));
}
[Fact]
public void TruncatedPackage_ReturnsNull_NeverThrows()
{
var full = ((MemoryStream)MsiBuilder.Build(new Dictionary<string, string> { ["ProductVersion"] = "1.0.0.0" })).ToArray();
foreach (var cut in new[] { 8, 511, 512, 600, 1024, full.Length / 2, full.Length - 1 })
{
var truncated = new MemoryStream(full.Take(cut).ToArray());
var ex = Record.Exception(() => MsiProductVersionReader.TryReadProductVersion(truncated));
Assert.Null(ex);
}
}
[Fact]
public void CyclicFatChain_ReturnsNull_NeverHangs()
{
var bytes = ((MemoryStream)MsiBuilder.Build(new Dictionary<string, string> { ["ProductVersion"] = "1.0.0.0" })).ToArray();
// FAT lives in sector 0 (offset 512). Point the directory sector's FAT entry back at itself.
var firstDirSector = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(0x30, 4));
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(512 + (int)firstDirSector * 4, 4), firstDirSector);
Assert.Null(MsiProductVersionReader.TryReadProductVersion(new MemoryStream(bytes)));
}
[Fact]
public void NonSeekableStream_ReturnsNull()
{
using var stream = new NonSeekableStream(((MemoryStream)MsiBuilder.Build(new Dictionary<string, string> { ["ProductVersion"] = "1.0.0.0" })).ToArray());
Assert.Null(MsiProductVersionReader.TryReadProductVersion(stream));
}
// =========================================================================
// Real packages (local oracle — the archived IME MSIs are not checked in)
// =========================================================================
/// <summary>
/// Sweeps every <c>ime-files/&lt;version&gt;/IntuneWindowsAgent.msi</c> in the repo checkout
/// (gitignored decompile scratch, populated by /ime-decompile) and asserts the reader
/// returns exactly the folder's version. Silently passes when the folder is absent (CI)
/// — the synthetic tests above carry the contract there; this one pins the string-pool
/// id convention against Microsoft's real packages.
/// </summary>
[Fact]
public void LocalImeArchive_EveryPackageReportsItsFolderVersion()
{
var root = FindRepoRoot();
var archive = root is null ? null : Path.Combine(root, "ime-files");
if (archive is null || !Directory.Exists(archive)) return;
var checkedCount = 0;
foreach (var dir in Directory.GetDirectories(archive))
{
var msiPath = Path.Combine(dir, "IntuneWindowsAgent.msi");
if (!File.Exists(msiPath)) continue;
var expected = Path.GetFileName(dir);
using var stream = File.OpenRead(msiPath);
var table = MsiProductVersionReader.ReadPropertyTable(stream);
Assert.NotNull(table);
Assert.Equal(expected, table!["ProductVersion"]);
Assert.Contains("Intune", table["ProductName"]);
checkedCount++;
}
Assert.True(checkedCount > 0, "ime-files exists but holds no packages");
}
private static string? FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln"))) return dir.FullName;
dir = dir.Parent;
}
return null;
}
private sealed class NonSeekableStream : MemoryStream
{
public NonSeekableStream(byte[] data) : base(data) { }
public override bool CanSeek => false;
}
// =========================================================================
// Minimal MSI (OLE compound file v3, 512-byte sectors) builder
// =========================================================================
internal static class MsiBuilder
{
public const string Hole = "\0<hole>";
private const int SectorSize = 512;
private const int MiniSectorSize = 64;
private const int MiniCutoff = 4096;
private const uint EndOfChain = 0xFFFFFFFE;
private const uint FreeSector = 0xFFFFFFFF;
private const uint NoStream = 0xFFFFFFFF;
private const string Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._";
public static string EncodeName(string name)
{
var sb = new StringBuilder();
var i = 0;
if (name.StartsWith('!'))
{
sb.Append((char)0x4840);
i = 1;
}
for (; i < name.Length; i += 2)
{
var a = Alphabet.IndexOf(name[i]);
if (a < 0) { sb.Append(name[i]); i--; continue; }
if (i + 1 < name.Length && Alphabet.IndexOf(name[i + 1]) >= 0)
sb.Append((char)(0x3800 + a + (Alphabet.IndexOf(name[i + 1]) << 6)));
else
sb.Append((char)(0x4800 + a));
}
return sb.ToString();
}
public static Stream Build(
Dictionary<string, string> properties,
string[]? extraStringsBeforeProperties = null,
bool threeByteRefs = false,
bool omitPropertyTable = false)
{
// ---- string pool ----
var pool = new List<byte>();
var data = new List<byte>();
var ids = new Dictionary<string, int>(StringComparer.Ordinal);
var codepage = 1252;
pool.AddRange(BitConverter.GetBytes((ushort)codepage));
pool.AddRange(BitConverter.GetBytes((ushort)(threeByteRefs ? 0x8000 : 0)));
var nextId = 1;
void AddString(string s)
{
if (s == Hole)
{
pool.AddRange(new byte[4]); // len 0, refs 0 — deleted string
nextId++;
return;
}
var bytes = Encoding.Latin1.GetBytes(s);
if (bytes.Length <= 0xFFFF)
{
pool.AddRange(BitConverter.GetBytes((ushort)bytes.Length));
pool.AddRange(BitConverter.GetBytes((ushort)1));
ids[s] = nextId++;
}
else
{
pool.AddRange(BitConverter.GetBytes((ushort)0));
pool.AddRange(BitConverter.GetBytes((ushort)1));
pool.AddRange(BitConverter.GetBytes((uint)bytes.Length));
ids[s] = nextId;
nextId += 2; // the length entry consumes a pool slot but no id
}
data.AddRange(bytes);
}
foreach (var s in extraStringsBeforeProperties ?? Array.Empty<string>()) AddString(s);
foreach (var kv in properties.OrderBy(k => k.Key, StringComparer.Ordinal))
{
if (!ids.ContainsKey(kv.Key)) AddString(kv.Key);
if (!ids.ContainsKey(kv.Value)) AddString(kv.Value);
}
// ---- Property table (column-major) ----
var refBytes = threeByteRefs ? 3 : 2;
var table = new List<byte>();
var ordered = properties.OrderBy(k => k.Key, StringComparer.Ordinal).ToList();
foreach (var kv in ordered) WriteRef(table, ids[kv.Key], refBytes);
foreach (var kv in ordered) WriteRef(table, ids[kv.Value], refBytes);
var streams = new List<(string Name, byte[] Bytes)>
{
(EncodeName("!_StringPool"), pool.ToArray()),
(EncodeName("!_StringData"), data.ToArray()),
("SummaryInformation", new byte[100]),
};
if (!omitPropertyTable) streams.Add((EncodeName("!Property"), table.ToArray()));
return Assemble(streams);
}
private static void WriteRef(List<byte> target, int id, int refBytes)
{
target.Add((byte)(id & 0xFF));
target.Add((byte)((id >> 8) & 0xFF));
if (refBytes == 3) target.Add((byte)((id >> 16) & 0xFF));
}
private static Stream Assemble(List<(string Name, byte[] Bytes)> streams)
{
// Mini stream: every stream < cutoff, padded to 64-byte mini sectors, one mini-FAT chain each.
var miniStream = new List<byte>();
var miniFat = new List<uint>();
var miniStarts = new Dictionary<int, uint>();
for (var i = 0; i < streams.Count; i++)
{
if (streams[i].Bytes.Length >= MiniCutoff) continue;
var count = Math.Max(1, (streams[i].Bytes.Length + MiniSectorSize - 1) / MiniSectorSize);
miniStarts[i] = (uint)miniFat.Count;
for (var s = 0; s < count; s++)
miniFat.Add(s == count - 1 ? EndOfChain : (uint)(miniFat.Count + 1));
miniStream.AddRange(streams[i].Bytes);
miniStream.AddRange(new byte[count * MiniSectorSize - streams[i].Bytes.Length]);
}
// Regular-sector payloads: directory, mini-FAT, mini stream, large streams.
var payloads = new List<byte[]>(); // index → bytes (each occupies ceil(len/512) sectors)
var dirIndex = payloads.Count; payloads.Add(Array.Empty<byte>()); // filled later
var miniFatIndex = payloads.Count; payloads.Add(miniFat.SelectMany(BitConverter.GetBytes).ToArray());
var miniStreamIndex = payloads.Count; payloads.Add(miniStream.ToArray());
var largeIndex = new Dictionary<int, int>();
for (var i = 0; i < streams.Count; i++)
{
if (streams[i].Bytes.Length < MiniCutoff) continue;
largeIndex[i] = payloads.Count;
payloads.Add(streams[i].Bytes);
}
// Directory entries: root + one per stream.
var dir = new byte[(streams.Count + 1) * 128];
static int SectorsFor(int len) => Math.Max(1, (len + SectorSize - 1) / SectorSize);
payloads[dirIndex] = dir;
// Sector map: FAT sectors first, then payloads in order.
var payloadSectors = payloads.Sum(p => SectorsFor(p.Length));
var fatSectors = 1;
while ((fatSectors + payloadSectors) > fatSectors * (SectorSize / 4)) fatSectors++;
var starts = new uint[payloads.Count];
var cursor = (uint)fatSectors;
for (var i = 0; i < payloads.Count; i++)
{
starts[i] = cursor;
cursor += (uint)SectorsFor(payloads[i].Length);
}
var totalSectors = (int)cursor;
// FAT
var fat = new uint[fatSectors * (SectorSize / 4)];
Array.Fill(fat, FreeSector);
for (var i = 0; i < fatSectors; i++) fat[i] = 0xFFFFFFFD; // FATSECT
for (var i = 0; i < payloads.Count; i++)
{
var n = SectorsFor(payloads[i].Length);
for (var s = 0; s < n; s++)
fat[starts[i] + s] = s == n - 1 ? EndOfChain : starts[i] + (uint)s + 1;
}
// Directory content
WriteDirEntry(dir, 0, "Root Entry", type: 5, left: NoStream, right: NoStream, child: 1,
start: starts[miniStreamIndex], size: (uint)miniStream.Count);
for (var i = 0; i < streams.Count; i++)
{
var (start, size) = streams[i].Bytes.Length >= MiniCutoff
? (starts[largeIndex[i]], (uint)streams[i].Bytes.Length)
: (miniStarts[i], (uint)streams[i].Bytes.Length);
WriteDirEntry(dir, i + 1, streams[i].Name, type: 2, left: NoStream,
right: i + 1 < streams.Count ? (uint)(i + 2) : NoStream, child: NoStream, start, size);
}
// Header
var header = new byte[SectorSize];
new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 }.CopyTo(header, 0);
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x18, 2), 0x3E); // minor
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x1A, 2), 3); // major
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x1C, 2), 0xFFFE);
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x1E, 2), 9);
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x20, 2), 6);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x2C, 4), (uint)fatSectors);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x30, 4), starts[dirIndex]);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x38, 4), MiniCutoff);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x3C, 4), starts[miniFatIndex]);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x40, 4), (uint)SectorsFor(payloads[miniFatIndex].Length));
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x44, 4), EndOfChain);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x48, 4), 0);
for (var i = 0; i < 109; i++)
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x4C + i * 4, 4), i < fatSectors ? (uint)i : FreeSector);
// Assemble file
var file = new byte[SectorSize + totalSectors * SectorSize];
header.CopyTo(file, 0);
for (var i = 0; i < fatSectors; i++)
for (var j = 0; j < SectorSize / 4; j++)
BinaryPrimitives.WriteUInt32LittleEndian(file.AsSpan(SectorSize + i * SectorSize + j * 4, 4), fat[i * (SectorSize / 4) + j]);
for (var i = 0; i < payloads.Count; i++)
payloads[i].CopyTo(file, SectorSize + (int)starts[i] * SectorSize);
return new MemoryStream(file);
}
private static void WriteDirEntry(byte[] dir, int index, string name, byte type,
uint left, uint right, uint child, uint start, uint size)
{
var off = index * 128;
var nameBytes = Encoding.Unicode.GetBytes(name);
nameBytes.CopyTo(dir, off);
BinaryPrimitives.WriteUInt16LittleEndian(dir.AsSpan(off + 64, 2), (ushort)(nameBytes.Length + 2));
dir[off + 66] = type;
dir[off + 67] = 1; // black
BinaryPrimitives.WriteUInt32LittleEndian(dir.AsSpan(off + 68, 4), left);
BinaryPrimitives.WriteUInt32LittleEndian(dir.AsSpan(off + 72, 4), right);
BinaryPrimitives.WriteUInt32LittleEndian(dir.AsSpan(off + 76, 4), child);
BinaryPrimitives.WriteUInt32LittleEndian(dir.AsSpan(off + 116, 4), start);
BinaryPrimitives.WriteUInt32LittleEndian(dir.AsSpan(off + 120, 4), size);
}
}
}
@@ -28,6 +28,8 @@ public class PolicyEnforcementMiddlewareTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
/// <summary>The oid every <see cref="AuthedPrincipal"/> carries and the default identity binding pins.</summary>
private const string TestOid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
private sealed class Harness
{
@@ -38,6 +40,18 @@ public class PolicyEnforcementMiddlewareTests
public void AsGlobalRole(string role) =>
Repo.Setup(r => r.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync(role);
/// <summary>
/// Re-homes the caller's identity binding (default: TenantA + <see cref="TestOid"/>). Cross-tenant roles
/// resolve ONLY for a principal whose tid/oid match this — use it for principals minted in TenantB.
/// </summary>
public void BoundTo(string tenantId, string objectId = TestOid) =>
Repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny<string>()))
.ReturnsAsync(new AdminIdentityBinding
{
TenantId = tenantId.ToLowerInvariant(),
ObjectId = objectId.ToLowerInvariant(),
});
public void AsTenantAdmin(string tenantId, string upn) =>
Repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new TenantMember
@@ -76,6 +90,10 @@ public class PolicyEnforcementMiddlewareTests
// No group assignments by default (delegated scope resolves direct grants + group tenants).
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()))
.ReturnsAsync(new List<TenantGroupAssignment>());
// Every UPN is identity-bound to TenantA + TestOid by default (matches AuthedPrincipal(TenantA, …));
// tests that mint the caller in TenantB re-home it via Harness.BoundTo.
repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny<string>()))
.ReturnsAsync(new AdminIdentityBinding { TenantId = TenantA, ObjectId = TestOid });
// Captured config repo: GetTenantConfigurationAsync returns null (tenant has no config row) so we can
// assert that authorization role resolution never PERSISTS a default row as a side effect.
@@ -84,11 +102,14 @@ public class PolicyEnforcementMiddlewareTests
.ReturnsAsync((TenantConfiguration?)null);
var cache = new MemoryCache(new MemoryCacheOptions());
var globalAdmin = new GlobalAdminService(repo.Object, cache, NullLogger<GlobalAdminService>.Instance);
// REAL binding service on the mocked repo: the middleware tests must exercise the identity check.
var bindings = new AdminIdentityBindingService(repo.Object, cache, NullLogger<AdminIdentityBindingService>.Instance);
var globalAdmin = new GlobalAdminService(repo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance);
// Entitlements stubbed to Enterprise — the delegated-rescue tests here pin AUTHORIZATION
// mechanics; the Enterprise-only edition gate is covered by DelegatedAdminEditionGateTests.
var delegatedAdmin = new DelegatedAdminService(
repo.Object,
bindings,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache,
NullLogger<DelegatedAdminService>.Instance);
@@ -102,8 +123,8 @@ public class PolicyEnforcementMiddlewareTests
return new Harness { Middleware = mw, Repo = repo, ConfigRepo = configRepo };
}
private static ClaimsPrincipal AuthedPrincipal(string tenantId, string upn)
=> new(new ClaimsIdentity(new[] { new Claim("tid", tenantId), new Claim("upn", upn) }, "TestAuth"));
private static ClaimsPrincipal AuthedPrincipal(string tenantId, string upn, string objectId = TestOid)
=> new(new ClaimsIdentity(new[] { new Claim("tid", tenantId), new Claim("upn", upn), new Claim("oid", objectId) }, "TestAuth"));
// ── ADDITIVE: GlobalReader + own-tenant Admin keeps IsTenantAdmin ──────────────
@@ -675,11 +696,13 @@ public class PolicyEnforcementMiddlewareTests
var reader = BuildHarness();
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
reader.BoundTo(TenantB);
var readerResult = await reader.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "reader@vendor.example"));
Assert.False(readerResult.Allowed, $"{method} {path} must deny a read-only Global Reader");
var delegated = BuildHarness();
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
delegated.BoundTo(TenantB);
var delegatedResult = await delegated.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "msp@partner.example"));
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin");
}
@@ -713,12 +736,14 @@ public class PolicyEnforcementMiddlewareTests
var reader = BuildHarness();
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
reader.BoundTo(TenantB);
var readerResult = await reader.Middleware.DecideAsync(
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantB, "reader@vendor.example"));
Assert.False(readerResult.Allowed, "read-only Global Reader must not patch config fields");
var delegated = BuildHarness();
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
delegated.BoundTo(TenantB);
var delegatedResult = await delegated.Middleware.DecideAsync(
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantB, "msp@partner.example"));
Assert.False(delegatedResult.Allowed, "delegated (MSP) admin must not patch config fields");
@@ -979,6 +1004,24 @@ public class PolicyEnforcementMiddlewareTests
Assert.Equal(403, result.StatusCode);
}
[Fact]
public void PreviewNotificationEmails_PluralRoute_DoesNotResolveToPerTenantEntry()
{
// The cross-tenant address map sits one character away from the per-tenant read
// (notification-email/{tenantId}). If the plural path ever resolved to that entry, the
// middleware would demand a tenantId the route does not carry — and RouteParam scoping
// would hand a delegated caller a rescue path into the whole map.
var plural = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/preview/notification-emails");
Assert.NotNull(plural);
Assert.Equal("preview/notification-emails", plural!.RouteTemplate);
Assert.Equal(TenantScoping.None, plural.TenantScoping);
// …and the per-tenant route is unaffected by the new sibling.
var single = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/preview/notification-email/tid-1");
Assert.NotNull(single);
Assert.Equal("preview/notification-email/{tenantId}", single!.RouteTemplate);
}
[Fact]
public async Task GlobalReader_ExcludeDelegatedRoutes_StillAllowed()
{
@@ -1051,6 +1094,7 @@ public class PolicyEnforcementMiddlewareTests
[InlineData("GET", "/api/global/customs-archive/tid-1/hist-1/arc-1")]
[InlineData("GET", "/api/global/tenants/tid-1/deletion-manifests")]
[InlineData("GET", "/api/preview/notification-email/tid-1")]
[InlineData("GET", "/api/preview/notification-emails")]
public void PlatformOperationalReads_ExcludeDelegated(string method, string path)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
@@ -1174,7 +1218,10 @@ public class PolicyEnforcementMiddlewareTests
Assert.True(result.Allowed);
Assert.Equal(upn, result.Context!.UserPrincipalName);
Assert.Equal(upn, result.Context!.CallerId);
// Throttle identity prefers the oid — unique per account, so two tenants sharing a UPN string
// (the API accepts tokens from any tenant) no longer share a bucket.
Assert.Equal(TestOid, result.Context!.CallerId);
Assert.Equal(TestOid, result.Context!.ObjectId);
}
// ── CallerId: throttle identity survives tokens that carry no UPN ──────────────
@@ -26,9 +26,20 @@ public class RevokeEnforcementTests
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string GroupId = "tpl-aaaaaaaa";
private static readonly StubAdminIdentityBindingService Bindings = new(bound: true);
/// <summary>Resolver stub: never consulted by the revoke/validation seams under test (loose mock ⇒ null).</summary>
private static AdminIdentityResolver Resolver() =>
new Mock<AdminIdentityResolver>(
Mock.Of<IMetricsRepository>(),
new Mock<TenantConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false }.Object,
NullLogger<AdminIdentityResolver>.Instance) { CallBase = false }.Object;
private static DelegatedAdminService BuildService(Mock<IAdminRepository> repo) =>
new(
repo.Object,
Bindings,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<DelegatedAdminService>.Instance);
@@ -55,7 +66,7 @@ public class RevokeEnforcementTests
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new DelegatedAdminManagementFunction(
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), audit.Object, signalR);
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR);
return (fn, repo, audit, signalR);
}
@@ -72,7 +83,7 @@ public class RevokeEnforcementTests
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new TenantGroupManagementFunction(
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), audit.Object, signalR);
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR);
return (fn, repo, audit, signalR);
}
@@ -101,11 +112,40 @@ public class RevokeEnforcementTests
DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = tenantId }, out _));
[Theory]
[InlineData("partner.example")]
[InlineData("11111111-1111-1111-1111")]
public void ValidateGrant_NonGuidHomeTenant_Rejected(string homeTenantId)
{
// The home tenant is OPTIONAL at the API (resolved from sign-in history / UPN domain when absent),
// but a supplied value must be a GUID — a typo must not become a binding.
var error = DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = homeTenantId }, out _);
Assert.NotNull(error);
Assert.Contains("homeTenantId", error);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void ValidateGrant_AbsentHomeTenant_IsValid_ResolvedLater(string? homeTenantId)
=> Assert.Null(DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = homeTenantId }, out _));
[Fact]
public void ValidateGrant_NonGuidObjectId_Rejected()
{
var error = DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = TenantB, ObjectId = "not-a-guid" }, out _);
Assert.NotNull(error);
Assert.Contains("objectId", error);
}
[Fact]
public void ValidateGrant_UnknownRole_Rejected()
{
var error = DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, Role = "GlobalAdmin" }, out _);
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = TenantB, Role = "GlobalAdmin" }, out _);
Assert.NotNull(error);
Assert.Contains(Constants.DelegatedRoles.DelegatedReader, error);
}
@@ -114,7 +154,7 @@ public class RevokeEnforcementTests
public void ValidateGrant_EmptyRole_DefaultsToLeastPrivilegedReader()
{
Assert.Null(DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA }, out var role));
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = TenantB }, out var role));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, role);
}
@@ -122,7 +162,11 @@ public class RevokeEnforcementTests
public void ValidateGrant_ExplicitDelegatedAdmin_Accepted()
{
Assert.Null(DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, Role = Constants.DelegatedRoles.DelegatedAdmin },
new GrantDelegatedAdminRequest
{
Upn = Upn, TenantId = TenantA, HomeTenantId = TenantB,
ObjectId = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa", Role = Constants.DelegatedRoles.DelegatedAdmin,
},
out var role));
Assert.Equal(Constants.DelegatedRoles.DelegatedAdmin, role);
}
@@ -0,0 +1,54 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Test stub for <see cref="AdminIdentityBindingService"/> — answers the identity-binding check with a
/// fixed verdict and records grant-time bindings in memory, without any storage dependency. Used wherever
/// a service under test consumes the binding check but the test's subject is something else (row/role
/// resolution, the entitlement gate, revoke enforcement). The binding logic itself is pinned by
/// <see cref="AdminIdentityBindingServiceTests"/>; the end-to-end authorization effect by
/// <see cref="AdminIdentityBindingAuthorizationTests"/>.
/// </summary>
internal sealed class StubAdminIdentityBindingService : AdminIdentityBindingService
{
private readonly Func<AdminIdentity?, bool> _verdict;
/// <summary>Every (upn, tenantId, objectId) passed to EnsureBoundAsync / RebindAsync, in call order.</summary>
public List<(string Upn, string TenantId, string? ObjectId)> Bindings { get; } = new();
public StubAdminIdentityBindingService(bool bound = true) : this(_ => bound)
{
}
public StubAdminIdentityBindingService(Func<AdminIdentity?, bool> verdict)
: base(Mock.Of<IAdminRepository>(), new MemoryCache(new MemoryCacheOptions()),
NullLogger<AdminIdentityBindingService>.Instance)
{
_verdict = verdict;
}
public override Task<bool> IsBoundAsync(AdminIdentity? identity) => Task.FromResult(_verdict(identity));
public override Task<AdminIdentityBinding> EnsureBoundAsync(string upn, string tenantId, string? objectId, string boundBy)
{
Bindings.Add((upn.ToLowerInvariant(), tenantId.ToLowerInvariant(), objectId?.ToLowerInvariant()));
return Task.FromResult(new AdminIdentityBinding
{
Upn = upn.ToLowerInvariant(),
TenantId = tenantId.ToLowerInvariant(),
ObjectId = objectId?.ToLowerInvariant() ?? string.Empty,
BoundBy = boundBy,
});
}
public override Task<AdminIdentityBinding> RebindAsync(string upn, string tenantId, string? objectId, string boundBy)
=> EnsureBoundAsync(upn, tenantId, objectId, boundBy);
public override Task RemoveAsync(string upn) => Task.CompletedTask;
}
@@ -22,6 +22,7 @@ public class TenantApprovalServiceTests
private readonly Mock<PreviewWhitelistService> _previewMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<IEmailService> _emailMock;
private readonly List<OpsEventEntry> _opsEvents = new();
private readonly TenantApprovalService _sut;
public TenantApprovalServiceTests()
@@ -40,7 +41,23 @@ public class TenantApprovalServiceTests
_emailMock = new Mock<IEmailService>();
_emailMock.Setup(x => x.SendPreviewApprovedEmailAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Real OpsEventService over a capturing repository: its Record* helpers are not virtual,
// so the assertions below read the entries the service actually composes.
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (_opsEvents) _opsEvents.Add(e); })
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), configRepo,
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
_sut = new TenantApprovalService(
NullLogger<TenantApprovalService>.Instance,
@@ -48,7 +65,21 @@ public class TenantApprovalServiceTests
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
_emailMock.Object);
_emailMock.Object,
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch));
}
private OpsEventEntry? OpsEventOfType(string eventType)
{
lock (_opsEvents) return _opsEvents.FirstOrDefault(e => e.EventType == eventType);
}
private static TenantConfiguration ConfigWith(string? contactEmail, string domainName = "contoso.com")
{
var config = TenantConfiguration.CreateDefault(TenantId);
config.DomainName = domainName;
config.ContactEmail = contactEmail;
return config;
}
[Fact]
@@ -87,15 +118,87 @@ public class TenantApprovalServiceTests
// permanently suppress the mail), and a lost marker race must not send.
[Fact]
public async Task TrySendWelcomeEmail_NoAddressYet_DefersWithoutConsumingMarker()
public async Task TrySendWelcomeEmail_NoAddressAnywhere_DefersWithoutConsumingMarker_AndRecordsSkip()
{
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(ConfigWith(contactEmail: null));
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.False(sent);
_previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny<string>()), Times.Never);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
// The silent skip is exactly what hid the August 2026 outage — it must leave a record.
var skipped = OpsEventOfType("WelcomeEmailSkipped");
Assert.NotNull(skipped);
Assert.Equal(OpsEventSeverity.Warning, skipped!.Severity);
Assert.Equal(TenantId, skipped.TenantId);
}
[Fact]
public async Task TrySendWelcomeEmail_NoActivationAddress_FallsBackToTenantContactAddress()
{
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
.ReturnsAsync(ConfigWith(contactEmail: "contact@contoso.com"));
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.True(sent);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("contact@contoso.com", "contoso.com", It.IsAny<CancellationToken>()), Times.Once);
Assert.Null(OpsEventOfType("WelcomeEmailSkipped"));
Assert.Contains("tenant contact address", OpsEventOfType("WelcomeEmailSent")!.Message);
}
[Fact]
public async Task TrySendWelcomeEmail_ActivationAddressWinsOverContactAddress()
{
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("chosen@contoso.com");
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
.ReturnsAsync(ConfigWith(contactEmail: "contact@contoso.com"));
Assert.True(await _sut.TrySendWelcomeEmailAsync(TenantId));
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("chosen@contoso.com", "contoso.com", It.IsAny<CancellationToken>()), Times.Once);
Assert.Contains("activation page", OpsEventOfType("WelcomeEmailSent")!.Message);
}
[Fact]
public async Task TrySendWelcomeEmail_SignupUpnIsNeverAFallback()
{
// Admin UPNs frequently have no mailbox; bouncing them costs sender reputation for
// every other tenant. Only the two deliberate addresses may be used.
var config = ConfigWith(contactEmail: null);
config.OnboardedBy = "adm.someone@contoso.com";
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
Assert.False(await _sut.TrySendWelcomeEmailAsync(TenantId));
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task TrySendWelcomeEmail_ProviderRefused_ReleasesMarker_AndRecordsFailure()
{
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(ConfigWith(contactEmail: null));
_emailMock.Setup(x => x.SendPreviewApprovedEmailAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.False(sent);
// The marker records delivery, not an attempt — a refused send must stay retryable.
_previewMock.Verify(x => x.ClearWelcomeEmailSentMarkerAsync(TenantId), Times.Once);
var failed = OpsEventOfType("WelcomeEmailFailed");
Assert.NotNull(failed);
Assert.Equal(OpsEventSeverity.Error, failed!.Severity);
}
[Fact]
@@ -118,6 +221,7 @@ public class TenantApprovalServiceTests
{
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(false);
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(ConfigWith(contactEmail: null));
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
@@ -131,6 +235,7 @@ public class TenantApprovalServiceTests
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId))
.ThrowsAsync(new InvalidOperationException("storage down"));
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(ConfigWith(contactEmail: null));
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
@@ -44,15 +44,6 @@ public class TenantAutoApproveHandlerTests
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
{ CallBase = false };
_approvalMock = new Mock<TenantApprovalService>(
NullLogger<TenantApprovalService>.Instance,
_previewMock.Object,
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
Mock.Of<IEmailService>())
{ CallBase = false };
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
@@ -66,6 +57,16 @@ public class TenantAutoApproveHandlerTests
NullLogger<OpsAlertDispatchService>.Instance);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
_approvalMock = new Mock<TenantApprovalService>(
NullLogger<TenantApprovalService>.Instance,
_previewMock.Object,
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
Mock.Of<IEmailService>(),
opsService)
{ CallBase = false };
_sut = new TenantAutoApproveHandler(
NullLogger<TenantAutoApproveHandler>.Instance,
_adminConfigMock.Object,
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -35,6 +37,8 @@ public class TenantConfigPatchServiceTests
public Mock<IConfigRepository> Repo { get; } = new();
public Mock<IConfigBackupRepository> Backup { get; } = new();
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public Mock<IOpsEventRepository> OpsRepo { get; } = new();
public List<OpsEventEntry> OpsEvents { get; } = new();
public TenantConfigPatchService Sut { get; }
public TenantConfiguration? Current;
@@ -89,8 +93,23 @@ public class TenantConfigPatchServiceTests
Repo.Object, NullLogger<TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
OpsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (OpsEvents) OpsEvents.Add(e); })
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var opsService = new OpsEventService(OpsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
Sut = new TenantConfigPatchService(
Repo.Object, Backup.Object, configService, Maintenance.Object,
Repo.Object, Backup.Object, configService, Maintenance.Object, opsService,
NullLogger<TenantConfigPatchService>.Instance);
}
@@ -116,6 +135,41 @@ public class TenantConfigPatchServiceTests
return o;
}
// ── Ops visibility: Collect Logs capability flip ────────────────────────
[Fact]
public async Task Patch_EnablingDiagnosticsUpload_EmitsOpsEvent_OnlyOnTheFlip()
{
var stored = Stored();
stored.DiagnosticsUploadMode = "Off";
var harness = new Harness(stored);
// Settings page / MCP: turn the capability on (mode + Hosted destination).
var on = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("diagnosticsUploadMode", "OnFailure"), ("diagnosticsUploadDestination", "Hosted")),
Ga, "mcp-patch", "enable collect logs");
Assert.True(on.Success, on.Error);
var enabled = Assert.Single(harness.OpsEvents);
Assert.Equal("DiagnosticsUploadEnabled", enabled.EventType);
Assert.Equal(TenantId, enabled.TenantId);
Assert.Contains("contoso.com", enabled.Message);
Assert.Contains("mcp-patch", enabled.Message);
// A further edit that keeps it on is not a flip.
var tweak = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("diagnosticsUploadMode", "Always")), Ga, "mcp-patch", null);
Assert.True(tweak.Success, tweak.Error);
Assert.Single(harness.OpsEvents);
// Turning it off is the opposite flip.
var off = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("diagnosticsUploadMode", "Off")), Ga, "mcp-patch", null);
Assert.True(off.Success, off.Error);
Assert.Equal(2, harness.OpsEvents.Count);
Assert.Equal("DiagnosticsUploadDisabled", harness.OpsEvents[1].EventType);
}
// ── Happy path ──────────────────────────────────────────────────────────
[Fact]
@@ -89,10 +89,79 @@ public class VerdictCalibrationRadarTests
}
[Fact]
public void Derived_legacy_paths_never_alert()
public void Derived_legacy_paths_never_alert_per_path_but_feed_the_group_sums()
{
var rows = Horizon(w => new[] { ("legacy:r6", "Incomplete", w ? 10 : 1), ("agent:complete", "Succeeded", w ? 10 : 19) });
Assert.DoesNotContain(VerdictCalibrationRadar.Evaluate(rows, Target), f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
var findings = VerdictCalibrationRadar.Evaluate(rows, Target);
Assert.DoesNotContain(findings, f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
// Legacy continuity: the rule-shaped legacy subset counts into the silence group, so a
// real silence jump stays visible even when the history predates the instrumentation.
Assert.Contains(findings, f => f.Kind == VerdictCalibrationAlertKinds.SilenceShareRegression);
}
[Fact]
public void Share_regression_requires_an_established_baseline()
{
// The 2026-08-23 instrumentation-cutover shape: the same decisions exist only under the
// legacy name in the baseline and only under the new name in the window. Zero baseline
// hits is new vocabulary, not a regression — and the legacy continuity keeps the silence
// group flat across the rename (production fired a lift-124 artifact without both).
var rows = Horizon(w => new[]
{
("sweep:r5_incomplete", "Incomplete", w ? 4 : 0),
("legacy:r5_incomplete", "Incomplete", w ? 0 : 4),
("agent:complete", "Succeeded", 16),
});
var findings = VerdictCalibrationRadar.Evaluate(rows, Target);
Assert.DoesNotContain(findings, f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
Assert.DoesNotContain(findings, f => f.Kind == VerdictCalibrationAlertKinds.SilenceShareRegression);
}
[Fact]
public void Share_regression_needs_ten_window_hits()
{
// 7 window hits at 5% vs an established 1.07% baseline — lift ~4.7, but verdict shares
// on single-digit counts are noise (a 3-hit episode survived as such, read 2026-08-27).
var rows = new List<VerdictCalibrationDailyAggregate>();
for (var i = 34; i >= 0; i--)
{
var inWindow = i < 7;
rows.Add(new VerdictCalibrationDailyAggregate
{
TenantId = "t", Date = Target.AddDays(-i).ToString("yyyy-MM-dd"), SessionCount = 20, TerminalSessionCount = 20,
Buckets =
{
new VerdictCalibrationBucket { VerdictPath = "sweep:r6", Status = "Incomplete", Count = inWindow ? 1 : (i == 20 ? 6 : 0) },
new VerdictCalibrationBucket { VerdictPath = "agent:complete", Status = "Succeeded", Count = inWindow ? 19 : 20 },
},
});
}
Assert.DoesNotContain(VerdictCalibrationRadar.Evaluate(rows, Target),
f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
}
[Fact]
public void ReArm_clears_episodes_below_the_fire_floors()
{
// A zero-baseline episode from before the floors existed (rename artifact) clears itself
// even while its share is elevated…
var artifact = new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.ShareRegression, VerdictPath = "sweep:r5_incomplete", Status = "Incomplete" };
var renamed = Horizon(w => new[]
{
("sweep:r5_incomplete", "Incomplete", w ? 4 : 0),
("legacy:r5_incomplete", "Incomplete", w ? 0 : 4),
("agent:complete", "Succeeded", 16),
});
Assert.True(VerdictCalibrationRadar.ShouldReArm(artifact, renamed, Target));
// …and so does one whose window has shrunk under the 10-hit floor.
var shrunk = new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.ShareRegression, VerdictPath = "maxlife:r5_awaiting", Status = "AwaitingUser" };
var tiny = Horizon(w => new[]
{
("maxlife:r5_awaiting", "AwaitingUser", 1),
("agent:complete", "Succeeded", 19),
});
Assert.True(VerdictCalibrationRadar.ShouldReArm(shrunk, tiny, Target));
}
[Fact]
@@ -60,6 +60,7 @@ public class VerdictPathTests
Assert.Equal("sweep:r5_incomplete", path);
Assert.Equal("sweep", VerdictPaths.Origin(path));
Assert.True(VerdictPaths.IsClassifierPath(path));
Assert.True(VerdictPaths.IsClassifierPath("sweep:r5_assumed"));
Assert.True(VerdictPaths.IsClassifierPath("maxlife:r1"));
Assert.True(VerdictPaths.IsClassifierPath("late:r4"));
Assert.True(VerdictPaths.IsClassifierPath("retro:r6"));
@@ -107,6 +108,7 @@ public class VerdictPathTests
Assert.Equal(ClassifierRules.R3EmergencyBreak, RuleOf(new[] { Esp(DeviceSetup44), Evt("agent_emergency_break") }));
Assert.Equal(ClassifierRules.R4DesktopHello, RuleOf(new[] { Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("hello_provisioning_completed") }));
Assert.Equal(ClassifierRules.R5DeviceSetupAwaiting, RuleOf(new[] { Esp(DeviceSetup44), Esp(AccountSetup15) }, hours: 6, grace: 72));
Assert.Equal(ClassifierRules.R5DesktopAssumed, RuleOf(new[] { Esp(DeviceSetup44), Esp(AccountSetup15), Evt("desktop_arrived") }, hours: 80, grace: 72));
Assert.Equal(ClassifierRules.R5DeviceSetupIncomplete, RuleOf(new[] { Esp(DeviceSetup44), Esp(AccountSetup15) }, hours: 80, grace: 72));
Assert.Equal(ClassifierRules.R6Fallthrough, RuleOf(Array.Empty<EnrollmentEvent>()));
}
@@ -48,6 +48,25 @@ public class VulnerabilitySummaryAggregatorTests
Assert.Equal(1, r.DistinctCves);
}
[Fact]
public void TopCve_CarriesMaxEpssAndHighestPriority_NullWhenNothingScored()
{
var scored = Row("CVE-E", "s1", "Medium", 5.0); scored.EpssScore = 0.12; scored.Priority = "attend";
var scoredLower = Row("CVE-E", "s2", "Medium", 5.0); scoredLower.EpssScore = 0.03; scoredLower.Priority = "track";
var legacy = Row("CVE-E", "s3", "Medium", 5.0); // pre-EPSS row: null score, empty priority
var unscored = Row("CVE-U", "s1", "Low", 2.0);
var r = VulnerabilitySummaryAggregator.Aggregate(new[] { scored, scoredLower, legacy, unscored }, false, 20, 30, false, Now);
var e = r.TopCves.Single(c => c.CveId == "CVE-E");
Assert.Equal(0.12, e.EpssScore);
Assert.Equal("attend", e.Priority);
var u = r.TopCves.Single(c => c.CveId == "CVE-U");
Assert.Null(u.EpssScore);
Assert.Equal("", u.Priority);
}
[Fact]
public void BuildsSeverityBreakdownByDistinctCveAndCountsKev()
{
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
private readonly TableClient _delegatedAdminsTableClient;
private readonly TableClient _tenantGroupsTableClient;
private readonly TableClient _tenantGroupAssignmentsTableClient;
private readonly TableClient _identityBindingsTableClient;
private readonly ILogger<TableAdminRepository> _logger;
public TableAdminRepository(
@@ -32,6 +33,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
_delegatedAdminsTableClient = storage.GetTableClient(Constants.TableNames.DelegatedAdmins);
_tenantGroupsTableClient = storage.GetTableClient(Constants.TableNames.TenantGroups);
_tenantGroupAssignmentsTableClient = storage.GetTableClient(Constants.TableNames.TenantGroupAssignments);
_identityBindingsTableClient = storage.GetTableClient(Constants.TableNames.AdminIdentityBindings);
}
// --- Global Admins ---
@@ -670,6 +672,112 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return entries;
}
// --- Admin identity bindings ---
public async Task<AdminIdentityBinding?> GetIdentityBindingAsync(string upn)
{
if (string.IsNullOrWhiteSpace(upn))
return null;
try
{
var result = await _identityBindingsTableClient.GetEntityAsync<AdminIdentityBindingEntity>(
AdminIdentityBindingEntity.Partition, upn.ToLowerInvariant());
return result.Value == null ? null : MapToBinding(result.Value);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
// Any other storage failure propagates: the callers treat "unknown binding" as no role, so a
// swallowed error here would only turn an outage into a silent, confusing 403.
}
public async Task<List<AdminIdentityBinding>> GetAllIdentityBindingsAsync()
{
var bindings = new List<AdminIdentityBinding>();
await foreach (var entity in _identityBindingsTableClient.QueryAsync<AdminIdentityBindingEntity>(
filter: $"PartitionKey eq '{AdminIdentityBindingEntity.Partition}'"))
{
bindings.Add(MapToBinding(entity));
}
return bindings;
}
public async Task<bool> UpsertIdentityBindingAsync(string upn, string tenantId, string? objectId, string boundBy)
{
var now = DateTime.UtcNow;
var pinned = !string.IsNullOrWhiteSpace(objectId);
var entity = new AdminIdentityBindingEntity
{
PartitionKey = AdminIdentityBindingEntity.Partition,
RowKey = upn.ToLowerInvariant(),
Upn = upn.ToLowerInvariant(),
TenantId = tenantId.ToLowerInvariant(),
ObjectId = pinned ? objectId!.ToLowerInvariant() : string.Empty,
BoundBy = boundBy?.ToLowerInvariant() ?? string.Empty,
BoundDate = now,
ObjectIdPinnedDate = pinned ? now : null
};
// Replace (not merge): a rebind must drop a previously pinned object id, never inherit it.
await _identityBindingsTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
return true;
}
public async Task<AdminIdentityBinding?> TryPinIdentityObjectIdAsync(string upn, string tenantId, string objectId)
{
upn = upn.ToLowerInvariant();
try
{
var result = await _identityBindingsTableClient.GetEntityAsync<AdminIdentityBindingEntity>(
AdminIdentityBindingEntity.Partition, upn);
var entity = result.Value;
if (entity == null)
return null;
// Pin only onto an unpinned binding homed in the caller's tenant; anything else is returned
// untouched so the caller's verdict (mismatch) is based on what is actually stored.
if (!string.IsNullOrEmpty(entity.ObjectId)
|| !string.Equals(entity.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
return MapToBinding(entity);
entity.ObjectId = objectId.ToLowerInvariant();
entity.ObjectIdPinnedDate = DateTime.UtcNow;
// ETag-conditional: if another instance pinned concurrently (412), re-read and let the
// caller compare against whatever won — never overwrite a competing pin.
await _identityBindingsTableClient.UpdateEntityAsync(entity, entity.ETag, TableUpdateMode.Replace);
return MapToBinding(entity);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
catch (RequestFailedException ex) when (ex.Status == 412)
{
_logger.LogWarning("[IdentityBinding] Concurrent object-id pin for {Upn} — re-reading stored binding", upn);
return await GetIdentityBindingAsync(upn);
}
}
public async Task<bool> RemoveIdentityBindingAsync(string upn)
{
await DeleteIfPresentAsync(_identityBindingsTableClient, AdminIdentityBindingEntity.Partition, upn.ToLowerInvariant());
return true;
}
private static AdminIdentityBinding MapToBinding(AdminIdentityBindingEntity entity)
{
return new AdminIdentityBinding
{
Upn = entity.Upn,
TenantId = entity.TenantId,
ObjectId = entity.ObjectId ?? string.Empty,
BoundBy = entity.BoundBy,
BoundAt = entity.BoundDate,
ObjectIdPinnedAt = entity.ObjectIdPinnedDate
};
}
// --- Tenant Members ---
public async Task<List<TenantMember>> GetTenantMembersAsync(string tenantId)
@@ -562,6 +562,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task<Dictionary<string, string>> GetAllNotificationEmailsAsync()
{
try
{
var emails = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
await foreach (var entity in _previewWhitelistTableClient.QueryAsync<PreviewNotificationEntity>(
filter: "RowKey eq 'notification-email'"))
{
if (!string.IsNullOrWhiteSpace(entity.Email))
emails[entity.PartitionKey.ToLowerInvariant()] = entity.Email;
}
return emails;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading notification emails");
throw;
}
}
public async Task SaveNotificationEmailAsync(string tenantId, string? email)
{
if (string.IsNullOrWhiteSpace(email))
@@ -878,6 +900,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "SessionDeletionKillSwitch", config.SessionDeletionKillSwitch },
{ "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc },
{ "MsrcLastSyncUtc", config.MsrcLastSyncUtc },
{ "NvdCacheLastRefreshUtc", config.NvdCacheLastRefreshUtc },
{ "EpssLastSyncUtc", config.EpssLastSyncUtc },
{ "MaxDiagnosticsDownloadSizeMB", config.MaxDiagnosticsDownloadSizeMB },
{ "DiagnosticsDownloadTimeoutSeconds", config.DiagnosticsDownloadTimeoutSeconds },
{ "OpsEventRetentionDays", config.OpsEventRetentionDays },
@@ -947,6 +971,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
SessionDeletionKillSwitch = entity.GetBoolean("SessionDeletionKillSwitch") ?? false,
VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc"),
MsrcLastSyncUtc = entity.GetString("MsrcLastSyncUtc"),
NvdCacheLastRefreshUtc = entity.GetString("NvdCacheLastRefreshUtc"),
EpssLastSyncUtc = entity.GetString("EpssLastSyncUtc"),
MaxDiagnosticsDownloadSizeMB = entity.GetInt32("MaxDiagnosticsDownloadSizeMB") ?? 500,
DiagnosticsDownloadTimeoutSeconds = entity.GetInt32("DiagnosticsDownloadTimeoutSeconds") ?? 120,
OpsEventRetentionDays = entity.GetInt32("OpsEventRetentionDays") ?? 90,
@@ -86,6 +86,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<int> DeleteUserActivityOlderThanAsync(DateTime cutoffUtc)
=> _storage.DeleteUserActivityOlderThanAsync(cutoffUtc);
public Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
=> _storage.GetSignInIdentitiesByUpnAsync(upn);
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
@@ -169,7 +169,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task MarkExcessiveEventsAutoActionedAsync(string tenantId, string sessionId)
=> _storage.MarkExcessiveEventsAutoActionedAsync(tenantId, sessionId);
public Task<bool> RecordImeVersionAsync(string version, string tenantId, string sessionId)
public Task<ImeVersionSighting> RecordImeVersionAsync(string version, string tenantId, string sessionId)
=> _storage.RecordImeVersionAsync(version, tenantId, sessionId);
public Task<List<ImeVersionHistoryEntry>> GetImeVersionHistoryAsync()
@@ -43,7 +43,12 @@ public static class ClaimsPrincipalExtensions
/// <summary>
/// Gets the user's UPN (User Principal Name) from token claims
/// Falls back to preferred_username if upn is not available
/// Supports both v1.0 and v2.0 token formats
/// Supports both v1.0 and v2.0 token formats.
/// <para>
/// Display / audit / row-key use only. Both claims are mutable and reusable across tenants, so no
/// cross-tenant authorization is keyed on this value alone — role resolution takes the full
/// <see cref="Security.AdminIdentity"/> (upn + tid + oid) and verifies it against the identity binding.
/// </para>
/// </summary>
public static string? GetUserPrincipalName(this ClaimsPrincipal principal)
{
@@ -55,8 +60,10 @@ public static class ClaimsPrincipalExtensions
/// <summary>
/// Stable per-caller identity for THROTTLING keys — never for authorization or audit.
/// <para>
/// Prefers the UPN so a human caller keeps the same bucket as before. Falls back to the token
/// subject (<c>oid</c> → <c>sub</c> → <c>appid</c>/<c>azp</c>) because a valid token need not carry
/// Prefers the object id (<c>oid</c>): it is unique per Entra account, so two accounts in different
/// tenants that happen to share a UPN string (the API accepts tokens from any tenant) get separate
/// buckets instead of throttling each other. Falls back to the UPN, then to the remaining token
/// subject claims (<c>sub</c> → <c>appid</c>/<c>azp</c>) because a valid token need not carry
/// a user identity at all: AuthenticationMiddleware validates issuer/audience/lifetime/signature
/// but requires no <c>upn</c>, so an app-only (client-credentials) token authenticates fine and
/// reaches every AuthenticatedUser route. Without this fallback such a caller would present an
@@ -71,9 +78,8 @@ public static class ClaimsPrincipalExtensions
/// </summary>
public static string? GetCallerId(this ClaimsPrincipal principal)
{
return principal.GetUserPrincipalName()
?? principal.FindFirst("oid")?.Value
?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value
return principal.GetObjectId()
?? principal.GetUserPrincipalName()
?? principal.FindFirst("sub")?.Value
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst("appid")?.Value
@@ -24,17 +24,20 @@ public class DelegatedAdminManagementFunction
{
private readonly ILogger<DelegatedAdminManagementFunction> _logger;
private readonly DelegatedAdminService _delegatedAdminService;
private readonly AdminIdentityResolver _identityResolver;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
public DelegatedAdminManagementFunction(
ILogger<DelegatedAdminManagementFunction> logger,
DelegatedAdminService delegatedAdminService,
AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
_identityResolver = identityResolver;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
}
@@ -68,14 +71,28 @@ public class DelegatedAdminManagementFunction
if (validationError != null)
return await Bad(req, validationError);
// Edition note: TARGET tenants may be any edition — an MSP on Pro may manage Community
// customers. The Pro requirement applies to the delegated admin's HOME tenant and is
// enforced at resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid). It cannot
// be checked here: at grant time only the UPN is known, and UPN-domain → tenant mapping is not
// reliable (multi-domain tenants). Grants to non-Pro-homed admins are simply inert.
var entry = await _delegatedAdminService.UpsertAsync(
body!.Upn, body.TenantId, role,
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "");
// The UPN's identity binding (home tenant + object id) is resolved automatically — sign-in history,
// then UPN domain → onboarded tenant; the body may override it. Unresolvable ⇒ 422 and the UI asks
// the operator to pick the tenant. Edition note: TARGET tenants may be any edition — an MSP on Pro
// may manage Community customers; the Pro requirement applies to the HOME tenant and is enforced at
// resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid), so grants to non-Pro-homed
// admins are simply inert. A binding conflict (UPN already homed elsewhere) is a 409, never a silent rebind.
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body!.Upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
return await Unresolved(req);
DelegatedAdminEntry entry;
try
{
entry = await _delegatedAdminService.UpsertAsync(
body.Upn, body.TenantId, role,
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "",
identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
return await Conflict(req, ex.Message);
}
await _maintenanceRepo.LogAuditEntryAsync(
entry.TenantId, "CREATE", "DelegatedAdmin", entry.Upn, currentUpn ?? "",
@@ -84,6 +101,7 @@ public class DelegatedAdminManagementFunction
{ "Role", entry.Role },
{ "Status", entry.Status },
{ "Source", entry.Source },
{ "HomeTenantId", identity.Value.TenantId.ToLowerInvariant() },
});
_logger.LogInformation("Delegated grant: {Upn} -> {TenantId} ({Role}) by {By}",
@@ -211,6 +229,10 @@ public class DelegatedAdminManagementFunction
if (!Guid.TryParse(body.TenantId, out _))
return "a valid tenantId (GUID) is required";
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
return bindingError;
role = string.IsNullOrWhiteSpace(body.Role) ? Constants.DelegatedRoles.DelegatedReader : body.Role!;
if (role != Constants.DelegatedRoles.DelegatedReader && role != Constants.DelegatedRoles.DelegatedAdmin)
return $"role must be '{Constants.DelegatedRoles.DelegatedReader}' or '{Constants.DelegatedRoles.DelegatedAdmin}'";
@@ -224,11 +246,30 @@ public class DelegatedAdminManagementFunction
await bad.WriteAsJsonAsync(new { error });
return bad;
}
private static async Task<HttpResponseData> Conflict(HttpRequestData req, string error)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new { error });
return conflict;
}
private static async Task<HttpResponseData> Unresolved(HttpRequestData req)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
}
public class GrantDelegatedAdminRequest
{
public string Upn { get; set; } = string.Empty;
/// <summary>The MANAGED (target) tenant the UPN may read.</summary>
public string TenantId { get; set; } = string.Empty;
public string? Role { get; set; }
/// <summary>The grantee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
public string? HomeTenantId { get; set; }
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
public string? ObjectId { get; set; }
}
@@ -0,0 +1,190 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin;
/// <summary>
/// GlobalAdmin management of <b>admin identity bindings</b> — the immutable Entra identity (home tenant id +
/// object id) behind every UPN that holds a cross-tenant role (GlobalAdmins, DelegatedAdmins, TenantGroupAssignments).
/// Grants create a binding implicitly and REFUSE to overwrite one (409); this surface is the explicit, audited
/// path for the two legitimate changes: re-homing a UPN in another tenant, and re-pinning the object id after
/// the UPN was re-assigned to a new account (PUT without objectId clears the pin so the next sign-in from the
/// bound tenant re-pins). DELETE makes every role row of the UPN inert without touching the rows.
///
/// Reads are GlobalReadOrAdmin; mutations are GlobalAdminOnly (route catalog). Audited under the binding's
/// HOME tenant with entity <c>AdminIdentityBinding</c>.
/// </summary>
public class IdentityBindingManagementFunction
{
private readonly ILogger<IdentityBindingManagementFunction> _logger;
private readonly AdminIdentityBindingService _bindings;
private readonly IMaintenanceRepository _maintenanceRepo;
private const string AuditEntity = "AdminIdentityBinding";
public IdentityBindingManagementFunction(
ILogger<IdentityBindingManagementFunction> logger,
AdminIdentityBindingService bindings,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_bindings = bindings;
_maintenanceRepo = maintenanceRepo;
}
/// <summary>GET /api/global/identity-bindings — every binding. GlobalReadOrAdmin.</summary>
[Function("GetIdentityBindings")]
[Authorize]
public async Task<HttpResponseData> GetIdentityBindings(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/identity-bindings")] HttpRequestData req)
{
var bindings = await _bindings.GetAllAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { bindings });
return response;
}
/// <summary>
/// PUT /api/global/identity-bindings/{upn} — create or REPLACE the binding. GlobalAdminOnly.
/// Body: { "homeTenantId": "&lt;GUID&gt;", "objectId": "&lt;GUID, optional&gt;" }. Omitting objectId clears
/// any pinned object id (re-pinned on the next sign-in from the home tenant).
/// </summary>
[Function("PutIdentityBinding")]
[Authorize]
public async Task<HttpResponseData> PutIdentityBinding(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/identity-bindings/{upn}")] HttpRequestData req,
string upn, FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
upn = upn.ToLowerInvariant();
var body = await req.ReadFromJsonAsync<IdentityBindingRequest>();
var error = IdentityBindingRequest.Validate(body?.HomeTenantId, body?.ObjectId);
if (error != null)
return await Bad(req, error);
var previous = await _bindings.GetAsync(upn);
var binding = await _bindings.RebindAsync(upn, body!.HomeTenantId!, body.ObjectId, currentUpn ?? "");
var details = new Dictionary<string, string>
{
{ "HomeTenantId", binding.TenantId },
{ "ObjectId", binding.IsObjectIdPinned ? binding.ObjectId : "(unpinned)" },
};
if (previous != null)
{
details["PreviousHomeTenantId"] = previous.TenantId;
details["PreviousObjectId"] = previous.IsObjectIdPinned ? previous.ObjectId : "(unpinned)";
}
await _maintenanceRepo.LogAuditEntryAsync(
binding.TenantId, previous == null ? "CREATE" : "UPDATE", AuditEntity, upn, currentUpn ?? "", details);
// Warning on purpose: every rebind is a security-relevant, operator-initiated identity change.
_logger.LogWarning("[IdentityBinding] {Action} binding for {Upn}: tenant {TenantId}, objectId {ObjectId} by {By}",
previous == null ? "Created" : "Replaced", upn, binding.TenantId,
binding.IsObjectIdPinned ? binding.ObjectId : "(unpinned)", currentUpn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { binding });
return response;
}
/// <summary>DELETE /api/global/identity-bindings/{upn} — remove the binding (all role rows of the UPN become inert). GlobalAdminOnly.</summary>
[Function("DeleteIdentityBinding")]
[Authorize]
public async Task<HttpResponseData> DeleteIdentityBinding(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/identity-bindings/{upn}")] HttpRequestData req,
string upn, FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
upn = upn.ToLowerInvariant();
// Self-removal would lock the acting GA out of every GlobalAdminOnly route, including this one.
if (string.Equals(upn, currentUpn, StringComparison.OrdinalIgnoreCase))
return await Bad(req, "You cannot remove your own identity binding");
var previous = await _bindings.GetAsync(upn);
if (previous == null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { error = "Binding not found" });
return notFound;
}
await _bindings.RemoveAsync(upn);
await _maintenanceRepo.LogAuditEntryAsync(
previous.TenantId, "DELETE", AuditEntity, upn, currentUpn ?? "",
new Dictionary<string, string>
{
{ "HomeTenantId", previous.TenantId },
{ "ObjectId", previous.IsObjectIdPinned ? previous.ObjectId : "(unpinned)" },
});
_logger.LogWarning("[IdentityBinding] Removed binding for {Upn} (was tenant {TenantId}) by {By}",
upn, previous.TenantId, currentUpn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Binding removed" });
return response;
}
private static async Task<HttpResponseData> Bad(HttpRequestData req, string error)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { error });
return bad;
}
}
/// <summary>Body of PUT global/identity-bindings/{upn}; also the shared validator for the binding fields every grant carries.</summary>
public class IdentityBindingRequest
{
/// <summary>
/// The identity a grant binds: the operator-supplied values when present, otherwise the resolver's answer.
/// Returns null when neither yields a home tenant — the endpoint answers 422 <see cref="HomeTenantUnresolvedCode"/>
/// and the UI offers a tenant picker. A supplied object id always wins over a resolved one.
/// </summary>
public static async Task<(string TenantId, string? ObjectId)?> ResolveForGrantAsync(
AdminIdentityResolver resolver, string upn, string? homeTenantId, string? objectId)
{
objectId = string.IsNullOrWhiteSpace(objectId) ? null : objectId;
if (!string.IsNullOrWhiteSpace(homeTenantId))
return (homeTenantId, objectId);
var resolved = await resolver.ResolveAsync(upn);
return resolved == null ? null : (resolved.TenantId, objectId ?? resolved.ObjectId);
}
public string? HomeTenantId { get; set; }
public string? ObjectId { get; set; }
/// <summary>Returns the 400 message, or null when the fields are valid (home tenant = GUID required; object id = GUID or absent).</summary>
public static string? Validate(string? homeTenantId, string? objectId)
{
if (string.IsNullOrWhiteSpace(homeTenantId))
return "a valid homeTenantId (GUID — the Entra tenant the person signs in from) is required";
return ValidateOptional(homeTenantId, objectId);
}
/// <summary>
/// Grant-time variant: the home tenant MAY be absent (the backend resolves it from sign-in history or the
/// UPN domain — see <see cref="AdminIdentityResolver"/>); when supplied it must be a GUID, as must the object id.
/// </summary>
public static string? ValidateOptional(string? homeTenantId, string? objectId)
{
if (!string.IsNullOrWhiteSpace(homeTenantId) && !Guid.TryParse(homeTenantId, out _))
return "homeTenantId must be a GUID (the Entra tenant the person signs in from) when supplied";
if (!string.IsNullOrWhiteSpace(objectId) && !Guid.TryParse(objectId, out _))
return "objectId must be a GUID (the person's Entra object id) when supplied";
return null;
}
/// <summary>Error code the grant endpoints return (HTTP 422) when the home tenant could not be resolved automatically.</summary>
public const string HomeTenantUnresolvedCode = "HomeTenantUnresolved";
public const string HomeTenantUnresolvedMessage =
"The person's home tenant could not be resolved automatically (no previous sign-in, and the UPN domain does not belong to an onboarded tenant). Select the tenant they sign in from.";
}
@@ -29,6 +29,7 @@ public class TenantGroupManagementFunction
{
private readonly ILogger<TenantGroupManagementFunction> _logger;
private readonly DelegatedAdminService _delegatedAdminService;
private readonly AdminIdentityResolver _identityResolver;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
@@ -37,11 +38,13 @@ public class TenantGroupManagementFunction
public TenantGroupManagementFunction(
ILogger<TenantGroupManagementFunction> logger,
DelegatedAdminService delegatedAdminService,
AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
_identityResolver = identityResolver;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
}
@@ -223,7 +226,11 @@ public class TenantGroupManagementFunction
/// <summary>
/// POST /api/global/tenant-groups/{groupId}/assignees — assign a UPN to the group. GlobalAdminOnly.
/// Body: { "upn": "user@domain.com", "role": "DelegatedReader" | "DelegatedAdmin" }.
/// Body: { "upn": "user@domain.com", "role": "DelegatedReader" | "DelegatedAdmin",
/// "homeTenantId": "&lt;optional override&gt;", "objectId": "&lt;optional&gt;" }.
/// The UPN's identity binding is resolved automatically (sign-in history, then UPN domain → onboarded
/// tenant); 422 HomeTenantUnresolved when neither works, 409 if the UPN is already bound elsewhere
/// (rebind explicitly via global/identity-bindings).
/// </summary>
[Function("AssignTenantGroup")]
[Authorize]
@@ -242,14 +249,37 @@ public class TenantGroupManagementFunction
if (role != Constants.DelegatedRoles.DelegatedReader && role != Constants.DelegatedRoles.DelegatedAdmin)
return await Bad(req, $"role must be '{Constants.DelegatedRoles.DelegatedReader}' or '{Constants.DelegatedRoles.DelegatedAdmin}'");
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
return await Bad(req, bindingError);
// Guard against assigning to a non-existent group (would create an orphan assignment row).
var group = await _delegatedAdminService.GetGroupAsync(groupId);
if (group == null)
return await NotFound(req);
var upn = body.Upn.ToLowerInvariant();
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
// The service re-checks existence (covers a delete racing this assign) — skip the audit if it no-ops.
var assigned = await _delegatedAdminService.AssignGroupAsync(upn, groupId, role, true, currentUpn ?? "");
bool assigned;
try
{
assigned = await _delegatedAdminService.AssignGroupAsync(
upn, groupId, role, true, currentUpn ?? "", identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new { error = ex.Message });
return conflict;
}
if (!assigned)
return await NotFound(req);
@@ -365,4 +395,8 @@ public class AssignGroupRequest
{
public string Upn { get; set; } = string.Empty;
public string? Role { get; set; }
/// <summary>The assignee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
public string? HomeTenantId { get; set; }
/// <summary>The assignee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
public string? ObjectId { get; set; }
}
@@ -86,10 +86,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
// succeed right now (mode not Off + a usable destination). Members below Admin cannot
// read the full config, so this boolean is their only signal. Deliberately exposes no
// destination detail — just "would an upload work".
diagnosticsUploadConfigured =
!string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase)
&& GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(
config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination),
diagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
// Drives the "Autopilot Device Validation disabled" dashboard banner
// (useTenantSecurityConfig).
validateAutopilotDevice = config.ValidateAutopilotDevice,
@@ -21,17 +21,32 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly ILogger<UpdateTenantConfigurationFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEventService;
public UpdateTenantConfigurationFunction(
ILogger<UpdateTenantConfigurationFunction> logger,
TenantConfigurationService configService,
IMaintenanceRepository maintenanceRepo)
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEventService)
{
_logger = logger;
_configService = configService;
_maintenanceRepo = maintenanceRepo;
_opsEventService = opsEventService;
}
/// <summary>
/// Write-path label for the session-detail Collect Logs quick-config dialog. The dialog
/// sends <c>?intent=collect-logs</c> so the backup snapshot and the ops event can name
/// the path — a plain Settings/Admin PUT is <c>portal-put</c>. Allow-listed, never echoed
/// from arbitrary input.
/// </summary>
internal const string CollectLogsIntent = "collect-logs";
internal const string CollectLogsSource = "portal-collect-logs";
internal static string ResolveWriteSource(string? intent) =>
string.Equals(intent, CollectLogsIntent, StringComparison.OrdinalIgnoreCase) ? CollectLogsSource : "portal-put";
[Function("UpdateTenantConfiguration")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "config/{tenantId}")] HttpRequestData req,
@@ -122,8 +137,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.EnableEspContinueAnywayObservation != existingConfig.EnableEspContinueAnywayObservation ||
config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute ||
config.CustomUserRateLimitRequestsPerMinute != existingConfig.CustomUserRateLimitRequestsPerMinute ||
config.Disabled != existingConfig.Disabled ||
config.ValidateDeviceAssociation != existingConfig.ValidateDeviceAssociation)
config.Disabled != existingConfig.Disabled)
{
_logger.LogWarning(
"Tenant Admin {User} attempted to modify GA-only fields for tenant {TenantId}",
@@ -142,12 +156,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.Disabled = existingConfig.Disabled;
config.DisabledReason = existingConfig.DisabledReason;
config.DisabledUntil = existingConfig.DisabledUntil;
// DevPrep "Device association" toggle is GA-only during Private Preview.
// TODO(devprep-followup): missing xUnit test for this GA-gate — needs
// UpdateTenantConfigurationFunction test harness (mock HttpRequestData +
// RequestContext + TenantConfigurationService). Tracked in
// memory/project_devprep_followups.md.
config.ValidateDeviceAssociation = existingConfig.ValidateDeviceAssociation;
}
// App-reg homing is IMMUTABLE via the generic PUT — for ALL callers, GA included.
@@ -190,7 +198,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.TrialGrantedBy = existingConfig.TrialGrantedBy;
// Save configuration (retention cap already enforced by ValidateModel above)
await _configService.SaveConfigurationAsync(config, "portal-put", null);
var writeSource = ResolveWriteSource(req.Query["intent"]);
await _configService.SaveConfigurationAsync(config, writeSource, null);
var changes = ConfigDiffHelper.GetChanges(existingConfig, config);
await _maintenanceRepo.LogAuditEntryAsync(
@@ -202,6 +211,15 @@ namespace AutopilotMonitor.Functions.Functions.Config
changes.Count > 0 ? changes : null
);
// Operator visibility for the Collect Logs capability flip (the quick-config
// dialog writes through this PUT). OpsEventService never throws.
var diagChange = DiagnosticsUploadConfigChange.Detect(existingConfig, config);
if (diagChange != null)
{
await _opsEventService.RecordDiagnosticsUploadConfigChangedAsync(
requestCtx.TargetTenantId, config.DomainName, diagChange, userIdentifier ?? "unknown", writeSource);
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
@@ -31,6 +32,7 @@ public class AuthFunction
private readonly McpUserService _mcpUserService;
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
private readonly EntraAppRegistry _appRegistry;
private readonly AdminIdentityResolver _identityResolver;
public AuthFunction(
ILogger<AuthFunction> logger,
@@ -44,9 +46,11 @@ public class AuthFunction
GlobalNotificationService globalNotificationService,
McpUserService mcpUserService,
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
EntraAppRegistry appRegistry)
EntraAppRegistry appRegistry,
AdminIdentityResolver identityResolver)
{
_logger = logger;
_identityResolver = identityResolver;
_globalAdminService = globalAdminService;
_delegatedAdminService = delegatedAdminService;
_tenantConfigService = tenantConfigService;
@@ -99,12 +103,14 @@ public class AuthFunction
// A delegated-only external MSP login must not phantom-onboard its home tenant (see the side-effect
// gate below). A genuine first-user still gets their config created by HandleNewTenantDomainAsync.
var tenantConfigTask = _tenantConfigService.TryGetConfigurationAsync(tenantId);
var globalRoleTask = _globalAdminService.GetGlobalRoleAsync(upn);
// tenantId = JWT tid = the caller's home tenant — gates the delegated (MSP) scope (Pro-only seat).
var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(upn, tenantId);
// Cross-tenant roles resolve on the FULL identity (upn + tid + oid) against the UPN's identity
// binding — never on the UPN alone. Null (no oid in the token) ⇒ no platform/delegated role.
var identity = AdminIdentity.FromPrincipal(principal);
var globalRoleTask = _globalAdminService.GetGlobalRoleAsync(identity);
var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(identity);
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId);
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId);
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId, objectId);
var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId);
await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask,
@@ -200,7 +206,7 @@ public class AuthFunction
}
var upn = principal.GetUserPrincipalName();
var isAdmin = await _globalAdminService.IsGlobalAdminAsync(upn);
var isAdmin = await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal));
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { isGlobalAdmin = isAdmin, upn });
@@ -248,8 +254,34 @@ public class AuthFunction
await badRequestResponse.WriteAsJsonAsync(new { error = "UPN is required" });
return badRequestResponse;
}
// The row is inert until the UPN is bound to the identity that may use it. The home tenant is resolved
// automatically (sign-in history, then UPN domain → onboarded tenant); the body may override it.
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = bindingError });
return badRequestResponse;
}
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body.Upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
var newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!);
GlobalAdminEntity newAdmin;
try
{
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new { error = ex.Message });
return conflict;
}
_logger.LogInformation($"Global Admin added: {body.Upn} by {currentUpn}");
@@ -614,4 +646,8 @@ internal class AuthDecisionResult
public class AddGlobalAdminRequest
{
public string Upn { get; set; } = string.Empty;
/// <summary>The grantee's home Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
public string? HomeTenantId { get; set; }
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
public string? ObjectId { get; set; }
}
@@ -110,7 +110,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
// The route is registered as AuthenticatedUser, so PolicyEnforcementMiddleware
// does NOT compute IsGlobalAdmin; resolve it directly via GlobalAdminService.
var requestCtx = context.GetRequestContext();
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(requestCtx.UserPrincipalName);
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromRequestContext(requestCtx));
// Perform comprehensive health checks (endpoint URLs only for Global Admins)
var healthCheckResult = await _healthCheckService.PerformAllChecksAsync(includeEndpointUrls: isGlobalAdmin);
@@ -154,7 +154,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
// Authentication enforced by PolicyEnforcementMiddleware (AuthenticatedUser).
// The MCP server URL is infrastructure topology — include it only for Global Admins.
var requestCtx = context.GetRequestContext();
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(requestCtx.UserPrincipalName);
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromRequestContext(requestCtx));
var check = await _healthCheckService.CheckMcpServerAsync(includeEndpointUrl: isGlobalAdmin);
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -163,8 +163,9 @@ public class McpUserFunction
var principal = context.GetUser();
var upn = principal?.GetUserPrincipalName();
// JWT tid = the caller's home tenant — gates the delegated (MSP) auto-grant (Pro-only seat).
var result = await _mcpUserService.IsAllowedAsync(upn, principal?.GetTenantId());
// JWT tid + oid complete the caller's identity: platform-role and delegated (MSP) grants resolve on
// the identity binding (never the UPN alone); the delegated seat additionally requires a Pro home tenant.
var result = await _mcpUserService.IsAllowedAsync(upn, principal?.GetTenantId(), principal?.GetObjectId());
var response = req.CreateResponse(result.IsAllowed ? HttpStatusCode.OK : HttpStatusCode.Forbidden);
var payload = new Dictionary<string, object?>
@@ -1,5 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
@@ -105,7 +106,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
// realtime/groups/join is not a tenant-scoped route, so the middleware did not
// resolve the delegated scope onto RequestContext — resolve it here, bounded to
// the requested tenant only. Home tid gates the MSP seat (Pro-only).
var scope = await _delegatedAdminService.GetScopeAsync(userEmail, userTenantId);
var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromRequestContext(requestCtx));
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
if (allowedCrossTenant)
_logger.LogInformation($"Delegated user {userEmail} joining managed cross-tenant group: {request.GroupName}");
@@ -1,5 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -92,7 +93,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
var allowedCrossTenant = requestCtx.HasGlobalScope;
if (!allowedCrossTenant && !string.IsNullOrEmpty(userEmail))
{
var scope = await _delegatedAdminService.GetScopeAsync(userEmail, userTenantId);
var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromRequestContext(requestCtx));
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
}
@@ -78,6 +78,12 @@ namespace AutopilotMonitor.Functions.Functions.Rules
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
return conflict;
}
catch (ArgumentException ex)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { success = false, message = ex.Message });
return badReq;
}
}
[Function("UpdateAnalyzeRule")]
@@ -121,6 +127,12 @@ namespace AutopilotMonitor.Functions.Functions.Rules
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
return conflict;
}
catch (ArgumentException ex)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { success = false, message = ex.Message });
return badReq;
}
}
[Function("CreateAnalyzeRuleFromTemplate")]
@@ -132,6 +132,27 @@ public class PreviewWhitelistFunction
return response;
}
/// <summary>
/// GET /api/preview/notification-emails
/// Every stored notification address, keyed by lowercased tenant id. Global Admin +
/// read-only Global Reader. Exists so the tenant console can resolve an address back to
/// its tenant (a welcome mail shows the operator only the recipient, not who it was for)
/// without one point read per tenant.
/// </summary>
[Function("GetAllPreviewNotificationEmails")]
[Authorize]
public async Task<HttpResponseData> GetAllNotificationEmails(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "preview/notification-emails")] HttpRequestData req,
FunctionContext context)
{
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var emails = await _previewWhitelistService.GetAllNotificationEmailsAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { count = emails.Count, emails });
return response;
}
/// <summary>
/// PUT /api/preview/notification-email
/// Saves the caller's notification email for the activation notice.
@@ -221,15 +242,30 @@ public class PreviewWhitelistFunction
}
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
await _emailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName);
// Explicit GA send always sends; consume the once-only marker (best-effort) so
// the automatic paths won't produce a duplicate afterwards.
try { await _previewWhitelistService.TryMarkWelcomeEmailSentAsync(tenantId); }
catch { /* best-effort — a failed marker write must not fail the explicit send */ }
var sent = await _emailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName);
var principal = context.GetUser();
var upn = principal?.GetUserPrincipalName();
// This endpoint is the manual fallback for a welcome mail that did not go out on its
// own, so it must not report a success the provider never gave. Marker and message
// both follow the actual outcome — a refused send stays retryable.
if (!sent)
{
_logger.LogWarning(
"Welcome email to {Email} for tenant {TenantId} was not accepted by the provider (requested by {Upn})",
email, tenantId, upn);
var failed = req.CreateResponse(HttpStatusCode.BadGateway);
await failed.WriteAsJsonAsync(new { error = "The email provider did not accept the message", email });
return failed;
}
// Explicit GA send succeeded; consume the once-only marker (best-effort) so the
// automatic paths won't produce a duplicate afterwards.
try { await _previewWhitelistService.TryMarkWelcomeEmailSentAsync(tenantId); }
catch { /* best-effort — a failed marker write must not fail the explicit send */ }
_logger.LogInformation(
"Welcome email sent to {Email} for tenant {TenantId} by {Upn}",
email, tenantId, upn);
@@ -4,6 +4,7 @@ using System.IO;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.ApplicationInsights;
@@ -24,6 +25,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
private readonly ILogger<QueueSessionActionFunction> _logger;
private readonly ISessionRepository _sessionRepo;
private readonly TelemetryClient _telemetryClient;
private readonly OpsEventService _opsEventService;
private static readonly HashSet<string> AllowedTypes = new(StringComparer.OrdinalIgnoreCase)
{
@@ -57,11 +59,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
public QueueSessionActionFunction(
ILogger<QueueSessionActionFunction> logger,
ISessionRepository sessionRepo,
TelemetryClient telemetryClient)
TelemetryClient telemetryClient,
OpsEventService opsEventService)
{
_logger = logger;
_sessionRepo = sessionRepo;
_telemetryClient = telemetryClient;
_opsEventService = opsEventService;
}
/// <summary>
@@ -131,6 +135,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
return notFound;
}
// Ops feed: who uses the on-demand session controls (Collect Logs, quick-config,
// terminate). App Insights alone is invisible to alert rules. Never throws.
await _opsEventService.RecordSessionActionQueuedAsync(
tenantId, sessionId, action.Type, action.Reason, userIdentifier ?? "unknown");
_telemetryClient.TrackEvent("ServerActionQueued", new Dictionary<string, string>
{
{ "tenantId", tenantId },
@@ -90,7 +90,17 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
continue;
}
var cpeResponse = await _nvdApi.SearchCpeAsync(keyword);
var cpeFetch = await _nvdApi.SearchCpeAsync(keyword);
if (!cpeFetch.Succeeded)
{
failed.Add(new AutoResolveFailedItem
{
SoftwareName = item.SoftwareName,
Reason = cpeFetch.Throttled ? "nvd_throttled" : "nvd_unavailable"
});
continue;
}
var cpeResponse = cpeFetch.Data;
var bestMatch = CpeMatchScorer.SelectBestMatch(
cpeResponse.Products,
item.NormalizedVendor ?? item.Publisher ?? "",
@@ -19,17 +19,20 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
private readonly KevDataService _kevService;
private readonly MsrcCorrelationService _msrcService;
private readonly VulnerabilityCacheRefreshService _refresh;
private readonly AdminConfigurationService _adminConfigService;
private readonly ILogger<GetVulnerabilitySyncStatusFunction> _logger;
public GetVulnerabilitySyncStatusFunction(
KevDataService kevService,
MsrcCorrelationService msrcService,
VulnerabilityCacheRefreshService refresh,
AdminConfigurationService adminConfigService,
ILogger<GetVulnerabilitySyncStatusFunction> logger)
{
_kevService = kevService;
_msrcService = msrcService;
_refresh = refresh;
_adminConfigService = adminConfigService;
_logger = logger;
}
@@ -46,6 +49,9 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
await _msrcService.EnsureHydratedAsync();
var config = await _adminConfigService.GetConfigurationAsync();
var nvdCacheEntries = await _refresh.CountCveCacheRowsAsync();
var nvd = _refresh.LastNvdRefresh;
var epss = _refresh.LastEpssRefresh;
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
@@ -54,7 +60,26 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
kevLastSyncUtc = config?.VulnerabilityDataLastSyncUtc,
msrcIndexedCves = _msrcService.IndexSize,
msrcCoveredDocuments = _msrcService.CoveredDocuments.Count,
msrcLastSyncUtc = config?.MsrcLastSyncUtc
msrcLastSyncUtc = config?.MsrcLastSyncUtc,
nvdCacheEntries,
nvdCacheLastRefreshUtc = config?.NvdCacheLastRefreshUtc,
nvdRefreshRunning = _refresh.NvdRefreshRunning,
// Last run on THIS instance (in-memory; null after a cold start) — the
// persisted timestamp above is the durable "last completed" signal.
nvdLastRun = nvd == null ? null : new
{
stale = nvd.Stale, refreshed = nvd.Refreshed, failed = nvd.Failed,
skippedThrottled = nvd.SkippedThrottled, error = nvd.Error,
startedUtc = nvd.StartedUtc.ToString("o"), finishedUtc = nvd.FinishedUtc?.ToString("o"),
},
epssLastSyncUtc = config?.EpssLastSyncUtc,
epssRefreshRunning = _refresh.EpssRefreshRunning,
epssLastRun = epss == null ? null : new
{
rows = epss.Rows, cves = epss.Cves, scored = epss.Scored,
rowsRewritten = epss.RowsRewritten, error = epss.Error,
startedUtc = epss.StartedUtc.ToString("o"), finishedUtc = epss.FinishedUtc?.ToString("o"),
},
});
return response;
}
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Vulnerability;
using Microsoft.ApplicationInsights;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Manual FIRST EPSS re-score of every cached CVE (the EPSS phase of the daily timer). A
/// handful of batched requests — runs synchronously and answers with the counts. 409 when a
/// re-score is already in flight. Global Admin only.
/// </summary>
public class TriggerEpssSyncFunction
{
private readonly VulnerabilityCacheRefreshService _refresh;
private readonly TelemetryClient? _telemetryClient;
private readonly ILogger<TriggerEpssSyncFunction> _logger;
public TriggerEpssSyncFunction(
VulnerabilityCacheRefreshService refresh,
TelemetryClient? telemetryClient,
ILogger<TriggerEpssSyncFunction> logger)
{
_refresh = refresh;
_telemetryClient = telemetryClient;
_logger = logger;
}
[Function("TriggerEpssSync")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "vulnerability/sync-epss")] HttpRequestData req)
{
try
{
var result = await _refresh.RefreshEpssScoresAsync("manual");
if (result == null)
{
var busy = req.CreateResponse(HttpStatusCode.Conflict);
await busy.WriteAsJsonAsync(new { success = false, message = "An EPSS sync is already running" });
return busy;
}
var durationMs = (int)((result.FinishedUtc ?? DateTime.UtcNow) - result.StartedUtc).TotalMilliseconds;
_telemetryClient?.TrackEvent("ManualEpssSyncCompleted", new Dictionary<string, string>
{
{ "rows", result.Rows.ToString() },
{ "cves", result.Cves.ToString() },
{ "scored", result.Scored.ToString() },
{ "rowsRewritten", result.RowsRewritten.ToString() },
{ "error", result.Error ?? "" },
{ "durationMs", durationMs.ToString() }
});
var ok = result.Error == null;
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = ok,
message = ok
? $"EPSS scores refreshed: {result.Scored:N0} of {result.Cves:N0} cached CVEs scored, {result.RowsRewritten:N0} rows updated"
: $"EPSS sync failed: {result.Error}",
epssRows = result.Rows,
epssCves = result.Cves,
epssScored = result.Scored,
epssRowsRewritten = result.RowsRewritten,
epssError = result.Error,
durationMs,
syncedAt = DateTime.UtcNow.ToString("o")
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Manual EPSS sync failed");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "EPSS sync failed: " + ex.Message });
return errorResponse;
}
}
}
}
@@ -0,0 +1,51 @@
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Vulnerability;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Manual NVD cache refresh (the stale-row phase of the daily timer). The walk is sequential
/// and NVD-rate-limited, so it can run for minutes: this endpoint only STARTS it and answers
/// 202; progress and the outcome are visible on <c>/api/vulnerability/sync-status</c>
/// (<c>nvdRefreshRunning</c>, <c>nvdLastRun</c>). 409 when a walk is already in flight.
/// Global Admin only.
/// </summary>
public class TriggerNvdCacheRefreshFunction
{
private readonly VulnerabilityCacheRefreshService _refresh;
private readonly ILogger<TriggerNvdCacheRefreshFunction> _logger;
public TriggerNvdCacheRefreshFunction(VulnerabilityCacheRefreshService refresh, ILogger<TriggerNvdCacheRefreshFunction> logger)
{
_refresh = refresh;
_logger = logger;
}
[Function("TriggerNvdCacheRefresh")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "vulnerability/sync-nvd")] HttpRequestData req)
{
if (!_refresh.TryStartNvdRefreshInBackground("manual"))
{
var busy = req.CreateResponse(HttpStatusCode.Conflict);
await busy.WriteAsJsonAsync(new { success = false, message = "An NVD cache refresh is already running" });
return busy;
}
_logger.LogInformation("Manual NVD cache refresh started");
var response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteAsJsonAsync(new
{
success = true,
message = "NVD cache refresh started — stale entries are re-queried sequentially; check the sync status for progress",
startedAt = DateTime.UtcNow.ToString("o")
});
return response;
}
}
}
@@ -1,23 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.ApplicationInsights;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using static AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
{
/// <summary>
/// Timer-triggered function that refreshes vulnerability data caches daily.
/// - CISA KEV catalog (full in-memory refresh)
/// - MSRC CVRF index (last 6 Patch Tuesdays)
/// - Stale NVD CVE cache entries in VulnerabilityCache table (older than 7 days)
/// - FIRST EPSS re-score of every cached CVE
///
/// The NVD and EPSS walks live in <see cref="VulnerabilityCacheRefreshService"/>, shared with
/// the admin's manual buttons, so both paths behave identically.
///
/// All operations are logged to ILogger → visible in Application Insights under
/// "traces" with customDimensions.Category = "VulnerabilityDataSyncFunction".
@@ -26,26 +26,23 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
public class VulnerabilityDataSyncFunction
{
private readonly KevDataService _kevService;
private readonly NvdApiClient _nvdApi;
private readonly MsrcCorrelationService _msrcCorrelation;
private readonly IVulnerabilityRepository _vulnRepo;
private readonly VulnerabilityCacheRefreshService _refresh;
private readonly AdminConfigurationService _adminConfigService;
private readonly TelemetryClient? _telemetryClient;
private readonly ILogger<VulnerabilityDataSyncFunction> _logger;
public VulnerabilityDataSyncFunction(
KevDataService kevService,
NvdApiClient nvdApi,
MsrcCorrelationService msrcCorrelation,
IVulnerabilityRepository vulnRepo,
VulnerabilityCacheRefreshService refresh,
AdminConfigurationService adminConfigService,
TelemetryClient? telemetryClient,
ILogger<VulnerabilityDataSyncFunction> logger)
{
_kevService = kevService;
_nvdApi = nvdApi;
_msrcCorrelation = msrcCorrelation;
_vulnRepo = vulnRepo;
_refresh = refresh;
_adminConfigService = adminConfigService;
_telemetryClient = telemetryClient;
_logger = logger;
@@ -63,9 +60,6 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
int kevCount = 0;
string? kevError = null;
int cveRefreshed = 0;
int cveFailed = 0;
int cveStale = 0;
int msrcDocs = 0;
int msrcCves = 0;
string? msrcError = null;
@@ -107,15 +101,20 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
_logger.LogWarning(ex, "[VulnSync] MSRC index refresh failed");
}
// 3. Refresh stale CVE cache entries
var cveStats = await RefreshStaleCveCacheAsync();
cveStale = cveStats.Stale;
cveRefreshed = cveStats.Refreshed;
cveFailed = cveStats.Failed;
// 3. Refresh stale CVE cache entries (null = a manual walk is in flight; skip, it does the same work).
var cveStats = await _refresh.RefreshStaleCveCacheAsync("timer");
if (cveStats == null)
_logger.LogInformation("[VulnSync] CVE cache refresh skipped: a manual refresh is already running");
// 3b. Re-score EVERY cached CVE row with today's EPSS. Independent of the 7-day NVD
// TTL: EPSS is republished daily and the calls are cheap (one batch per 100 ids).
var epssStats = await _refresh.RefreshEpssScoresAsync("timer");
if (epssStats == null)
_logger.LogInformation("[VulnSync] EPSS refresh skipped: a manual sync is already running");
// 4. Update per-source sync timestamps. Only the sources that succeeded get
// bumped — a failed phase keeps its prior timestamp so the UI can surface
// "Last KEV/MSRC Sync" accurately.
// "Last KEV/MSRC Sync" accurately. (NVD/EPSS stamps are written by the service.)
try
{
var config = await _adminConfigService.GetConfigurationAsync();
@@ -153,183 +152,18 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
{ "msrcDocs", msrcDocs.ToString() },
{ "msrcCves", msrcCves.ToString() },
{ "msrcError", msrcError ?? "" },
{ "cveStale", cveStale.ToString() },
{ "cveRefreshed", cveRefreshed.ToString() },
{ "cveFailed", cveFailed.ToString() },
{ "cveStale", (cveStats?.Stale ?? 0).ToString() },
{ "cveRefreshed", (cveStats?.Refreshed ?? 0).ToString() },
{ "cveFailed", (cveStats?.Failed ?? 0).ToString() },
{ "cveSkippedThrottled", (cveStats?.SkippedThrottled ?? 0).ToString() },
{ "cveSkippedBusy", (cveStats == null).ToString() },
{ "epssRows", (epssStats?.Rows ?? 0).ToString() },
{ "epssCves", (epssStats?.Cves ?? 0).ToString() },
{ "epssScored", (epssStats?.Scored ?? 0).ToString() },
{ "epssRowsRewritten", (epssStats?.RowsRewritten ?? 0).ToString() },
{ "epssError", epssStats?.Error ?? "" },
{ "durationMs", durationMs.ToString("F0", System.Globalization.CultureInfo.InvariantCulture) }
});
}
private struct CveCacheStats
{
public int Stale;
public int Refreshed;
public int Failed;
}
/// <summary>
/// Scans the VulnerabilityCache table for CVE cache entries older than 7 days
/// and refreshes them by re-querying the NVD API. Processes sequentially with
/// rate limiting to avoid NVD API throttling.
/// </summary>
private async Task<CveCacheStats> RefreshStaleCveCacheAsync()
{
var stats = new CveCacheStats();
try
{
var staleCutoff = DateTime.UtcNow.AddDays(-7).ToString("o");
var cacheEntries = await _vulnRepo.GetStaleCveCacheEntriesAsync();
var staleEntries = new List<(string CacheKey, string CpeUri, string CachedAt, int TotalCves)>();
foreach (var entity in cacheEntries)
{
var pk = entity.ContainsKey("PartitionKey") ? entity["PartitionKey"]?.ToString() : null;
if (pk == null || !pk.StartsWith("cve_")) continue;
var cachedAt = entity.ContainsKey("CachedAt") ? entity["CachedAt"]?.ToString() ?? "" : "";
if (string.Compare(cachedAt, staleCutoff, StringComparison.OrdinalIgnoreCase) < 0)
{
staleEntries.Add((
pk,
entity.ContainsKey("CpeUri") ? entity["CpeUri"]?.ToString() ?? pk.Substring(4) : pk.Substring(4),
cachedAt,
entity.ContainsKey("TotalCves") && entity["TotalCves"] is int tc ? tc : 0
));
}
}
stats.Stale = staleEntries.Count;
if (staleEntries.Count == 0)
{
_logger.LogInformation("[VulnSync] CVE cache: no stale entries found");
return stats;
}
_logger.LogInformation("[VulnSync] CVE cache: {StaleCount} stale entries to refresh", staleEntries.Count);
int refreshed = 0;
int failed = 0;
foreach (var (cacheKey, cpeUri, cachedAt, totalCves) in staleEntries)
{
try
{
_logger.LogInformation("[VulnSync] Refreshing CVE cache for {CpeUri} (cached: {CachedAt}, had {TotalCves} CVEs)",
cpeUri, cachedAt, totalCves);
// Query NVD API (rate-limited internally by NvdApiClient)
var cveResponse = await _nvdApi.SearchCvesByCpeAsync(cpeUri, CancellationToken.None);
var newCount = cveResponse?.Vulnerabilities?.Count ?? 0;
// Project NVD response → CachedCve list (same shape the read path expects).
var cves = new List<CachedCve>();
if (cveResponse?.Vulnerabilities != null)
{
foreach (var vulnItem in cveResponse.Vulnerabilities)
{
var cve = vulnItem?.Cve;
if (cve == null) continue;
double cvssScore = 0;
string cvssSeverity = "UNKNOWN";
var cvssMetric = cve.Metrics?.CvssMetricV31?.FirstOrDefault()
?? cve.Metrics?.CvssMetricV30?.FirstOrDefault();
if (cvssMetric?.CvssData != null)
{
cvssScore = cvssMetric.CvssData.BaseScore;
cvssSeverity = cvssMetric.CvssData.BaseSeverity ?? "UNKNOWN";
}
var description = cve.Descriptions?.FirstOrDefault(d =>
string.Equals(d.Lang, "en", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
if (description.Length > 500)
description = description.Substring(0, 497) + "...";
var affectedVersions = new List<CachedVersionRange>();
if (cve.Configurations != null)
{
foreach (var config in cve.Configurations)
{
if (config.Nodes == null) continue;
foreach (var node in config.Nodes)
{
if (node.CpeMatch == null) continue;
foreach (var match in node.CpeMatch)
{
if (!match.Vulnerable) continue;
affectedVersions.Add(new CachedVersionRange
{
Criteria = match.Criteria,
VersionStartIncluding = match.VersionStartIncluding,
VersionStartExcluding = match.VersionStartExcluding,
VersionEndIncluding = match.VersionEndIncluding,
VersionEndExcluding = match.VersionEndExcluding
});
}
}
}
}
cves.Add(new CachedCve
{
CveId = cve.Id ?? "",
CvssScore = cvssScore,
CvssSeverity = cvssSeverity,
Description = description,
PublishedDate = cve.Published ?? "",
AffectedVersions = affectedVersions
});
}
}
// Apply the same cache budget the live FetchAndCache path uses,
// so the nightly refresh can't reintroduce oversized rows.
var budget = CveCacheBudget.Apply(cves, DateTime.UtcNow);
cves = budget.Cves;
var cveDataJson = System.Text.Json.JsonSerializer.Serialize(cves, JsonOptions);
var data = new Dictionary<string, object>
{
{ "CachedAt", DateTime.UtcNow.ToString("o") },
{ "CpeUri", cpeUri },
{ "TotalCves", cves.Count }
};
// Chunk CveDataJson across multiple properties if it exceeds 30K chars
foreach (var chunk in TableStorageChunking.ChunkProperty("CveDataJson", cveDataJson))
data[chunk.Key] = chunk.Value;
await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", data);
refreshed++;
_logger.LogInformation(
"[VulnSync] Refreshed {CpeUri}: {OldCount} → {NewCount} CVEs (NVD={Total}, afterDate={AfterDate}, truncRanges={TruncCves}, bytes={Bytes})",
cpeUri, totalCves, cves.Count,
budget.TotalIn, budget.KeptAfterDateFilter, budget.CvesWithTruncatedRanges, cveDataJson.Length);
}
catch (Exception ex)
{
failed++;
_logger.LogWarning(ex, "[VulnSync] Failed to refresh CVE cache for {CpeUri}", cpeUri);
}
}
_logger.LogInformation("[VulnSync] CVE cache refresh complete: {Refreshed} refreshed, {Failed} failed out of {Total} stale entries",
refreshed, failed, staleEntries.Count);
stats.Refreshed = refreshed;
stats.Failed = failed;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[VulnSync] CVE cache refresh scan failed");
}
return stats;
}
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared.Models.Vulnerability;
namespace AutopilotMonitor.Functions.Functions.Vulnerability
@@ -19,12 +20,18 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
/// <summary>Distinct-CVE counts grouped by their highest CVSS severity band.</summary>
public record SeverityBreakdown(int Critical, int High, int Medium, int Low);
/// <summary>A single CVE ranked by how many devices it affects in scope.</summary>
/// <summary>
/// A single CVE ranked by how many devices it affects in scope. <paramref name="EpssScore"/>
/// is the highest EPSS probability seen across the rows (null = unscored / legacy rows);
/// <paramref name="Priority"/> the highest act/attend/track band (empty on legacy rows).
/// </summary>
public record TopCve(
string CveId,
string CvssSeverity,
double CvssScore,
bool IsKev,
double? EpssScore,
string Priority,
int AffectedSessions,
IReadOnlyList<string> SampleSoftware);
@@ -76,6 +83,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
SeverityRank = SeverityRank(topSeverityRow.CvssSeverity),
CvssScore = g.Max(r => r.CvssScore),
IsKev = g.Any(r => r.IsKev),
// Max over a nullable ignores nulls and yields null when nothing is scored.
EpssScore = g.Max(r => r.EpssScore),
Priority = g.Select(r => r.Priority)
.Aggregate(string.Empty, (best, p) => CvePriority.Rank(p) > CvePriority.Rank(best) ? p : best),
AffectedSessions = g.Select(r => r.SessionId)
.Where(s => !string.IsNullOrEmpty(s))
.Distinct(StringComparer.OrdinalIgnoreCase).Count(),
@@ -93,7 +104,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
.ThenByDescending(c => c.CvssScore)
.ThenBy(c => c.CveId, StringComparer.OrdinalIgnoreCase)
.Take(topN)
.Select(c => new TopCve(c.CveId, c.Severity, c.CvssScore, c.IsKev, c.AffectedSessions, c.SampleSoftware))
.Select(c => new TopCve(c.CveId, c.Severity, c.CvssScore, c.IsKev, c.EpssScore, c.Priority, c.AffectedSessions, c.SampleSoftware))
.ToList();
var breakdown = new SeverityBreakdown(
@@ -17,9 +17,13 @@ public sealed record RequestContext
/// <summary>The user's Azure AD tenant ID (from JWT tid claim).</summary>
public string TenantId { get; init; } = string.Empty;
/// <summary>The user's UPN (from JWT upn/preferred_username claim).</summary>
/// <summary>The user's UPN (from JWT upn/preferred_username claim). Display/audit/row-key use — mutable.</summary>
public string UserPrincipalName { get; init; } = string.Empty;
/// <summary>The user's Entra object id (JWT oid). Together with <see cref="TenantId"/> the immutable half of
/// <see cref="Security.AdminIdentity"/>; empty when the token carried none (app-only) or no JWT at all.</summary>
public string ObjectId { get; init; } = string.Empty;
/// <summary>
/// Stable identity of the authenticated caller, for THROTTLING keys only — never authorization,
/// never audit (use <see cref="UserPrincipalName"/> there; this may be a bare object id).
@@ -38,8 +38,10 @@ public class VerdictCalibrationFinding
/// <item><b>evidence_gap</b> — absolute gate: the pure fallthrough rule (r6) decides ≥20 % of the
/// window's classifier verdicts. The evidence the other rules need is not arriving.</item>
/// </list>
/// Derived (<c>legacy:*</c>) paths are excluded from all three — pre-instrumentation attribution
/// is too weak to alert on. Agent-declared paths (<c>agent:*</c>, <c>register:*</c>) are excluded
/// Derived (<c>legacy:*</c>) paths never alert per-path — pre-instrumentation attribution is too
/// weak for that — but their rule-shaped subset DOES feed the silence and classifier-verdict
/// group sums so the groups stay continuous across the 2026-08-23 instrumentation cutover
/// (see <see cref="Summarize"/>). Agent-declared paths (<c>agent:*</c>, <c>register:*</c>) are excluded
/// from the per-path share regression: their share tracks the customer's workflow mix (a
/// WhiteGlove rollout week doubles <c>agent:whiteglove_pending</c>; a Continue-Anyway tenant
/// lives on <c>agent:complete_soft</c>) — real signals, but not about the BACKEND classifier
@@ -51,7 +53,21 @@ public static class VerdictCalibrationRadar
{
public const int WindowDays = RuleRegressionRadar.WindowDays;
public const int BaselineDays = RuleRegressionRadar.BaselineDays;
public const int MinWindowHits = RuleRegressionRadar.MinWindowHitSessions;
/// <summary>
/// Minimum window hits before a share finding fires or keeps its episode. Deliberately
/// higher than the rule radar's 5: verdict shares swing on tiny counts, and the first
/// production month kept a 3-hit maxlife:r5_awaiting episode alive as pure noise
/// (calibration read 2026-08-27).
/// </summary>
public const int MinWindowHits = 10;
/// <summary>
/// Minimum baseline hits before a PER-PATH share regression is meaningful. A path with no
/// established baseline cannot regress — it is new vocabulary (or a renamed path), and
/// alerting on it just counts the rollout: the 2026-08-23 instrumentation cut over from
/// legacy:* to sweep:*/register:* names and fired seven zero-baseline artifacts. The group
/// kinds are exempt — their baselines carry the legacy continuity (see <see cref="Summarize"/>).
/// </summary>
public const int MinBaselineHits = 5;
public const int MinWindowSessions = RuleRegressionRadar.MinWindowSessions;
public const double MinLift = RuleRegressionRadar.MinLift;
public const double ReArmLiftFactor = RuleRegressionRadar.ReArmLiftFactor;
@@ -135,7 +151,7 @@ public static class VerdictCalibrationRadar
.ToList();
}
/// <summary>True when an ACTIVE alert may re-arm given the current horizon sums (or when its path is no longer eligible — episodes from an earlier, broader gate clear themselves).</summary>
/// <summary>True when an ACTIVE alert may re-arm given the current horizon sums (or when its path/numbers no longer pass the fire gates — episodes from an earlier, broader gate clear themselves).</summary>
public static bool ShouldReArm(VerdictCalibrationAlert alert, IReadOnlyList<VerdictCalibrationDailyAggregate> tenantRows, DateTime targetDateUtc)
{
if (alert.Kind == VerdictCalibrationAlertKinds.ShareRegression && !IsShareRegressionEligible(alert.VerdictPath))
@@ -153,6 +169,9 @@ public static class VerdictCalibrationRadar
default:
horizon.Paths.TryGetValue((alert.VerdictPath, alert.Status), out var sums);
// Below the fire floors the episode is noise-retention, not an elevated share —
// clear it (it re-fires only by passing the full gates again).
if (sums.WindowHits < MinWindowHits || sums.BaselineHits < MinBaselineHits) return true;
return ReArmByRate(sums.WindowHits, horizon.WindowSessions, sums.BaselineHits, horizon.BaselineSessions);
}
}
@@ -189,6 +208,8 @@ public static class VerdictCalibrationRadar
if (windowHits < MinWindowHits) return null;
if (windowSessions < MinWindowSessions) return null;
if (baselineSessions <= 0) return null;
// Per-path only: no established baseline → no regression claim (new/renamed vocabulary).
if (kind == VerdictCalibrationAlertKinds.ShareRegression && baselineHits < MinBaselineHits) return null;
var windowRate = (double)windowHits / windowSessions;
var baselineRate = (double)baselineHits / baselineSessions;
@@ -212,6 +233,14 @@ public static class VerdictCalibrationRadar
};
}
/// <summary>Derived classifier-rule path (<c>legacy:r5_incomplete</c>) — same detail shape as <see cref="VerdictPaths.IsClassifierPath"/>, legacy origin.</summary>
private static bool IsLegacyClassifierPath(string path, string origin)
{
if (origin != LegacyOrigin) return false;
var detail = path.Length > origin.Length + 1 ? path.Substring(origin.Length + 1) : string.Empty;
return detail.Length >= 2 && detail[0] == 'r' && char.IsDigit(detail[1]);
}
internal readonly record struct PathSums(int WindowHits, int BaselineHits);
internal sealed class Horizon
@@ -234,6 +263,16 @@ public static class VerdictCalibrationRadar
/// question is "how often did the backend have to decide", not which rule did). Classifier
/// verdicts = stamped rule paths (<see cref="VerdictPaths.IsClassifierPath"/>); r6 = their
/// fallthrough subset.
/// Legacy continuity: derived rule-shaped paths (<c>legacy:r5_incomplete</c>, <c>legacy:r6</c>, …)
/// were the SAME backend decisions before the 2026-08-23 instrumentation — the derivation reads
/// the writers' own reason literals, which is strong enough for a group sum (though still too
/// weak for per-path alerting). Counting them symmetrically (window AND baseline) keeps the
/// silence and classifier-verdict groups continuous across the cutover; without this the groups
/// had a near-zero baseline by construction and fired a lift-124 artifact. Legacy rows only
/// exist for pre-instrumentation sessions, so the term self-deprecates as they age out.
/// (Non-rule legacy paths — <c>legacy:superseded</c>, <c>legacy:wg_awaiting</c>,
/// <c>legacy:unknown</c> — stay excluded: superseded is a registration write, unknown is
/// unattributable, and wg_awaiting is a negligible tail.)
/// </summary>
internal static Horizon Summarize(IReadOnlyList<VerdictCalibrationDailyAggregate> rows, DateTime targetDateUtc)
{
@@ -262,11 +301,12 @@ public static class VerdictCalibrationRadar
: new PathSums(sums.WindowHits, sums.BaselineHits + b.Count);
var origin = VerdictPaths.Origin(b.VerdictPath);
if (origin == VerdictPaths.OriginSweep || origin == VerdictPaths.OriginMaxLifetime)
var legacyRule = IsLegacyClassifierPath(b.VerdictPath, origin);
if (origin == VerdictPaths.OriginSweep || origin == VerdictPaths.OriginMaxLifetime || legacyRule)
{
if (inWindow) h.SilenceWindowHits += b.Count; else h.SilenceBaselineHits += b.Count;
}
if (VerdictPaths.IsClassifierPath(b.VerdictPath))
if (VerdictPaths.IsClassifierPath(b.VerdictPath) || legacyRule)
{
if (inWindow) h.ClassifierWindowVerdicts += b.Count; else h.ClassifierBaselineVerdicts += b.Count;
if (b.VerdictPath.EndsWith(":" + ClassifierRules.R6Fallthrough, StringComparison.Ordinal))
@@ -166,8 +166,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
var isScopedRoute = catalogEntry.TenantScoping is TenantScoping.RouteParam or TenantScoping.QueryParam;
if (!hasGlobalScope && !string.IsNullOrEmpty(upn) && isScopedRoute && crossTenant)
{
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require a Pro home tenant.
var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
// 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));
delegatedRole = scope.RoleFor(namedTarget);
if (delegatedRole != null)
allowedTenantIds = scope.TenantIds;
@@ -229,6 +230,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// user_ratelimit_anonymous bucket and 429'd agent telemetry fleet-wide. It stays on
// decision.UserIdentifier for log lines, where "anonymous" is the useful rendering.
UserPrincipalName = upn ?? string.Empty,
ObjectId = principal?.GetObjectId() ?? string.Empty,
// Throttle identity — NOT the same thing as the UPN. Empty only when the request carried
// no JWT at all (device/anonymous routes, which bring their own limits). An authenticated
// caller ALWAYS gets a non-empty value, including app-only tokens that carry no upn:
@@ -346,13 +348,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return await EvaluateBootstrapManagerOrGAAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.GlobalReadOrAdmin:
return await EvaluateGlobalReadOrAdminAsync(upn, userIdentifier);
return await EvaluateGlobalReadOrAdminAsync(principal, upn, userIdentifier);
case EndpointPolicy.GlobalReadOrDelegatedSubset:
return await EvaluateGlobalReadOrDelegatedSubsetAsync(tenantId, upn, userIdentifier);
return await EvaluateGlobalReadOrDelegatedSubsetAsync(principal, upn, userIdentifier);
case EndpointPolicy.GlobalAdminOnly:
return await EvaluateGlobalAdminOnlyAsync(upn, userIdentifier);
return await EvaluateGlobalAdminOnlyAsync(principal, upn, userIdentifier);
default:
return CatalogDecisionResult.Deny(userIdentifier, "N/A", $"UnknownPolicy:{entry.Policy}");
@@ -379,7 +381,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// (admin notification group, admin-audience notifications).
if (!string.IsNullOrEmpty(upn))
{
var globalRole = await _globalAdminService.GetGlobalRoleAsync(upn);
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != null)
{
var tenantRole = !string.IsNullOrEmpty(tenantId)
@@ -413,7 +415,7 @@ 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(upn);
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != null)
{
var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role;
@@ -442,7 +444,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await _globalAdminService.GetGlobalRoleAsync(upn);
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != null)
{
var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role;
@@ -462,7 +464,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(upn))
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
@@ -484,7 +486,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(upn))
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
@@ -500,7 +502,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(upn))
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
@@ -549,12 +551,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// Reader (e.g. for any in-function write gate).
/// </summary>
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrAdminAsync(
string? upn, string userIdentifier)
ClaimsPrincipal? principal, string? upn, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await _globalAdminService.GetGlobalRoleAsync(upn);
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != null)
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
@@ -570,17 +572,18 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// denied. Used by aggregate endpoints whose body can be filtered per tenant (e.g. config/all).
/// </summary>
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrDelegatedSubsetAsync(
string? jwtTenantId, string? upn, string userIdentifier)
ClaimsPrincipal? principal, string? upn, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await _globalAdminService.GetGlobalRoleAsync(upn);
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != null)
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require a Pro home tenant.
var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
// 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));
if (!scope.IsEmpty)
return CatalogDecisionResult.Allow(
userIdentifier, Constants.DelegatedRoles.DelegatedReader, "DelegatedSubset",
@@ -590,12 +593,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
}
private async Task<CatalogDecisionResult> EvaluateGlobalAdminOnlyAsync(
string? upn, string userIdentifier)
ClaimsPrincipal? principal, string? upn, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(upn))
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
return CatalogDecisionResult.Allow(userIdentifier, Constants.GlobalRoles.GlobalAdmin, "IsGA");
return CatalogDecisionResult.Deny(userIdentifier, "NonGA", "NotGlobalAdmin");
@@ -11,8 +11,9 @@ namespace AutopilotMonitor.Functions.Middleware;
/// <summary>
/// Per-caller rate limiting for authenticated (JWT) requests.
/// Runs after PolicyEnforcementMiddleware so RequestContext (CallerId, IsGlobalAdmin) is already resolved.
/// Buckets on RequestContext.CallerId — the throttle identity, which is the UPN for a human caller and
/// the token subject (oid/sub/appid) for a token that carries none. Device/bootstrap and anonymous
/// Buckets on RequestContext.CallerId — the throttle identity, which is the Entra object id for a human caller
/// (unique per account, so identical UPN strings from different tenants never share a bucket) and the
/// UPN / token subject (sub/appid) for a token that carries none. Device/bootstrap and anonymous
/// routes resolve to an EMPTY CallerId and are skipped here; they carry their own limits (per cert
/// thumbprint / per bootstrap token in SecurityValidator, per client IP on the public endpoints).
/// Keying on CallerId rather than UserPrincipalName is what keeps BOTH holes shut: the agent fleet out
@@ -250,6 +250,9 @@ builder.Services.AddSingleton<PlatformMetricsService>();
builder.Services.AddSingleton<AgentEfficiencyMetricsService>();
builder.Services.AddSingleton<SlaMetricsService>();
builder.Services.AddSingleton<SlaBreachEvaluationService>();
// Identity binding (tid + oid behind every cross-tenant-role UPN) — consulted by both role services.
builder.Services.AddSingleton<AdminIdentityBindingService>();
builder.Services.AddSingleton<AdminIdentityResolver>();
builder.Services.AddSingleton<GlobalAdminService>();
builder.Services.AddSingleton<DelegatedAdminService>();
builder.Services.AddSingleton<McpUserService>();
@@ -407,7 +410,10 @@ builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.MsrcApiClient>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.EpssApiClient>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.MsrcCorrelationService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCacheRefreshService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelationService>();
// Hydrate MSRC + KEV in-memory caches from blob snapshots at app startup (fire-and-forget;
// keeps cold-start fast for re-deploys, see VulnerabilityCacheWarmer for the contract).
@@ -0,0 +1,41 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security;
/// <summary>
/// The complete caller identity that cross-tenant role resolution is keyed on: the mutable, display-grade
/// UPN (the key under which role rows are stored) PLUS the immutable Entra pair that must match the UPN's
/// <see cref="Shared.DataAccess.AdminIdentityBinding"/> — the validated JWT <c>tid</c> and <c>oid</c>.
/// <para>
/// The API deliberately accepts tokens from ANY Entra tenant, and <c>upn</c>/<c>preferred_username</c> can be
/// re-created in a foreign tenant (domain re-registration) or re-assigned inside the home tenant (UPN recycling).
/// A UPN string alone therefore never resolves a GlobalAdmin/GlobalReader or delegated (MSP) role; only this
/// triple does. Construction is total: a principal lacking any of the three claims yields <c>null</c>, which
/// every consumer treats as "no cross-tenant role" (fail-closed).
/// </para>
/// </summary>
/// <param name="Upn">The UPN (lowercase) — the row key of the role tables.</param>
/// <param name="TenantId">The JWT <c>tid</c> (lowercase) — the caller's home tenant.</param>
/// <param name="ObjectId">The JWT <c>oid</c> (lowercase) — the caller's object id in that tenant.</param>
public sealed record AdminIdentity(string Upn, string TenantId, string ObjectId)
{
/// <summary>Builds the identity from a validated principal, or null when upn, tid or oid is missing.</summary>
public static AdminIdentity? FromPrincipal(ClaimsPrincipal? principal)
=> principal == null
? null
: Create(principal.GetUserPrincipalName(), principal.GetTenantId(), principal.GetObjectId());
/// <summary>Builds the identity from the middleware-resolved request context, or null when incomplete.</summary>
public static AdminIdentity? FromRequestContext(RequestContext context)
=> Create(context.UserPrincipalName, context.TenantId, context.ObjectId);
/// <summary>Normalizing factory: all three parts required (whitespace counts as missing), all lowercased.</summary>
public static AdminIdentity? Create(string? upn, string? tenantId, string? objectId)
{
if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(objectId))
return null;
return new AdminIdentity(upn.ToLowerInvariant(), tenantId.ToLowerInvariant(), objectId.ToLowerInvariant());
}
}
@@ -16,8 +16,7 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// True when the failure is transient and should be retried (Graph API error, token issue, network).
/// Transient failures are NOT cached. The caller is expected to surface 503 Retry-After
/// for the agent path and to log+swallow for shadow-mode paths.
/// Transient failures are NOT cached. The caller surfaces 503 Retry-After to the agent.
/// </summary>
public bool IsTransient { get; set; }
@@ -57,9 +56,8 @@ namespace AutopilotMonitor.Functions.Security
public string? ErrorMessage { get; set; }
/// <summary>
/// True when this result came from the in-memory cache rather than a fresh Graph lookup.
/// The shadow log uses it to emit one line per real lookup instead of one per request:
/// a single enrollment produced 263 identical lines off a handful of Graph calls.
/// True when this result came from the in-memory cache rather than a fresh Graph lookup
/// (kept for callers that want to log once per real lookup instead of once per request).
/// </summary>
public bool ServedFromCache { get; set; }
@@ -12,8 +12,8 @@ using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Validates devices against the Windows Autopilot Device Preparation (WDP)
/// "Device association" Graph API (<c>tenantAssociatedDevices</c>).
/// Validates devices against the Windows Autopilot device preparation
/// "Device association" Graph API (<c>tenantAssociatedDevices</c>) — GA since 2026-08-25.
///
/// Built as a near-sibling of <see cref="AutopilotDeviceValidator"/> — same DI dependencies,
/// same retry/cache/transient contract — so the resilience guarantees are identical:
@@ -179,7 +179,7 @@ namespace AutopilotMonitor.Functions.Security
{
IsValid = false,
SerialNumber = normalizedSerial,
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated for DevPrep"
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
};
JObject? data;
@@ -448,6 +448,10 @@ public static class EndpointAccessPolicyCatalog
// Private-preview welcome-email config — platform ONBOARDING artifact, GA + read-only Reader only,
// NOT delegated (an MSP manages active customer tenants, not the platform's preview onboarding).
new("GET", "preview/notification-email/{tenantId}", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam, excludeDelegated: true),
// Plural sibling: the cross-tenant address map behind the console's email search. Same
// GA/Reader-only intent as the per-tenant read above — excludeDelegated is redundant on an
// unscoped route but keeps the intent explicit if the route ever gains a scope.
new("GET", "preview/notification-emails", EndpointPolicy.GlobalReadOrAdmin, excludeDelegated: true),
new("POST", "preview/send-welcome-email/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
// Subset tier (like config/all): a delegated ("MSP") caller is admitted WITHOUT a named tenantId and
// the handler bounds the aggregate to RequestContext.AllowedTenantIds (its managed subset). With a
@@ -543,6 +547,8 @@ public static class EndpointAccessPolicyCatalog
new("GET", "vulnerability/software-inventory", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("POST", "vulnerability/sync", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/sync-msrc", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/sync-nvd", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/sync-epss", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/sync-status", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "vulnerability/cpe-mapping", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/cpe-mapping/auto-resolve", EndpointPolicy.GlobalAdminOnly),
@@ -584,6 +590,12 @@ public static class EndpointAccessPolicyCatalog
new("DELETE", "global/tenant-groups/{groupId}/tenants/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("POST", "global/tenant-groups/{groupId}/assignees", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "global/tenant-groups/{groupId}/assignees/{upn}", EndpointPolicy.GlobalAdminOnly),
// Admin identity bindings — the tid+oid behind every cross-tenant-role UPN. List is GlobalReadOrAdmin
// (audit "who can actually use this grant, from which tenant"); rebind/remove are GlobalAdminOnly and
// are the ONLY way to re-home a UPN or re-pin its object id (grants refuse to overwrite a binding).
new("GET", "global/identity-bindings", EndpointPolicy.GlobalReadOrAdmin),
new("PUT", "global/identity-bindings/{upn}", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "global/identity-bindings/{upn}", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "config/{tenantId}/plan", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
// Self-service Pro trial — a tenant admin may start their own tenant's one-time
// trial; the handler enforces the once-per-tenant + already-Pro conflicts (409).
@@ -241,14 +241,14 @@ namespace AutopilotMonitor.Functions.Security
// Security validation is always enforced (no longer configurable per tenant)
// Hard gate: tenant must enable at least one device validation method before agent traffic is accepted.
// Global Admins can set AllowInsecureAgentRequests=true in the config row for test tenants.
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.ValidateCloudPcDevice && !config.AllowInsecureAgentRequests)
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.ValidateDeviceAssociation && !config.ValidateCloudPcDevice && !config.AllowInsecureAgentRequests)
{
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Forbidden,
ErrorMessage = "Device validation is required",
Details = "Enable 'Autopilot Device Validation', 'Corporate Identifier Validation' or 'Windows 365 Cloud PC Validation' in Configuration before using the agent ingestion endpoints."
Details = "Enable 'Autopilot Device Validation', 'Corporate Identifier Validation', 'Device Association Validation' or 'Windows 365 Cloud PC Validation' in Configuration before using the agent ingestion endpoints."
};
}
@@ -399,6 +399,26 @@ namespace AutopilotMonitor.Functions.Security
}
}
// Autopilot device preparation "Device association" (GA since 2026-08-25): the device
// was pre-associated to this tenant in Intune (Devices > Enrollment > Device association)
// and Intune marks it corporate-owned itself — no corporate identifier upload exists for
// it, so the two lookups above miss. Same serial-based contract and resilience as the
// Autopilot lookup (30/5 min cache, transient → 503 below).
if (!deviceValidated && config.ValidateDeviceAssociation && _deviceAssociationValidator != null)
{
var associationResult = await _deviceAssociationValidator.LookupAsync(tenantId, serialNumber, sessionId);
if (associationResult.IsValid)
{
deviceValidated = true;
validatedBy = ValidatorType.DeviceAssociation;
}
else
{
deviceValidationError = CombineValidationErrors(deviceValidationError, associationResult.ErrorMessage);
deviceValidationTransient |= associationResult.IsTransient;
}
}
// W365 fallback: Cloud PCs are structurally never Autopilot-registered, so the serial
// lookups above always miss for them. Identity here comes from the chain-validated
// client certificate, NOT from spoofable headers: the Subject CN carries the Intune
@@ -421,7 +441,7 @@ namespace AutopilotMonitor.Functions.Security
}
}
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier || config.ValidateCloudPcDevice) && !deviceValidated)
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier || config.ValidateDeviceAssociation || config.ValidateCloudPcDevice) && !deviceValidated)
{
// Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries
// Definitive failures (device not registered) → 403 Forbidden
@@ -453,56 +473,17 @@ namespace AutopilotMonitor.Functions.Security
};
}
// 5. DevPrep "Device association" lookup (shadow mode — does NOT gate enrollment).
// Fire-and-forget: kicked off as a detached Task so it never adds latency to the
// RegisterSession/Ingest response. Graph round-trips for tenantAssociatedDevices can
// take 200ms–30s (504 timeouts observed), and shadow mode must never block the agent.
// Result is logged at Warning level so it surfaces in App Insights traces.
if (config.ValidateDeviceAssociation && _deviceAssociationValidator != null && !string.IsNullOrEmpty(serialNumber))
{
var validator = _deviceAssociationValidator;
var capturedTenant = tenantId;
var capturedSerial = serialNumber;
var capturedSession = sessionId;
var logger = _logger;
_ = Task.Run(async () =>
{
try
{
var devPrepResult = await validator.LookupAsync(capturedTenant, capturedSerial, capturedSession);
// One line per real Graph lookup, not per request — the same gate the
// cert-device-binding shadow uses. Measured 2026-08-24: a single enrollment
// produced 263 identical lines because the cache answered nearly every
// request. Uncached outcomes (transient failures) still log every time.
if (devPrepResult.ServedFromCache)
return;
logger.LogWarning(
"DevPrep association lookup (shadow) for tenant {TenantId}, session {SessionId}, serial {SerialNumber}: matched={Matched}, transient={Transient}, state={State}, policy={PolicyId}",
capturedTenant, capturedSession ?? "<none>", capturedSerial,
devPrepResult.IsValid, devPrepResult.IsTransient,
devPrepResult.AssociationState ?? "<none>", devPrepResult.DevicePreparationPolicyId ?? "<none>");
}
catch (Exception ex)
{
logger.LogWarning(ex, "DevPrep association lookup (shadow) failed for tenant {TenantId}, serial {SerialNumber} — ignored.", capturedTenant, capturedSerial);
}
});
}
// 6. CERT-DEVICE-BINDING-SHADOW - cert-to-device binding (Global-Admin-only preview).
// 5. CERT-DEVICE-BINDING-SHADOW - cert-to-device binding (Global-Admin-only preview).
// Stage 1 (CertTenantBinding) proves the certificate was issued to THIS tenant; this
// proves the specific device is one the tenant actually enrolled, so a certificate
// lifted from a decommissioned machine stops resolving. Observation only: it never
// touches deviceValidated or the returned result.
//
// Fire-and-forget for the same reason as the DevPrep lookup above - a cold Graph
// round-trip can take seconds and must never sit in the agent's request path. That
// rules out the request-row dimension stage 1 uses (the row is written the moment the
// function returns, before this task finishes), so the outcome is logged at Warning
// like the DevPrep shadow. Volume is bounded: only Global Admins can enable the
// Fire-and-forget: a cold Graph round-trip can take seconds and an observation-only
// check must never sit in the agent's request path. That rules out the request-row
// dimension stage 1 uses (the row is written the moment the function returns, before
// this task finishes), so the outcome is logged at Warning instead (one line per real
// Graph lookup, see ServedFromCache). Volume is bounded: only Global Admins can enable the
// toggle. Widening it beyond preview should move to an inline call plus the
// CertTenantBinding-style request dimension.
if (config.ValidateIntuneDeviceBinding && _intuneDeviceBindingValidator != null)
@@ -0,0 +1,202 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.DataAccess;
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Single choke-point that decides whether a caller's <see cref="AdminIdentity"/> IS the identity a
/// cross-tenant-role UPN was granted for. Both role services (<see cref="GlobalAdminService"/> and
/// <see cref="DelegatedAdminService"/>) consult it BEFORE resolving any role row, so a token from a foreign
/// tenant — or from a different account inside the home tenant — that merely carries a matching UPN string
/// resolves no platform or delegated role at all.
/// <para>
/// Binding model: <c>TenantId</c> is mandatory at grant time (the operator states which tenant the person is
/// homed in). <c>ObjectId</c> is either supplied at grant time or <b>pinned on the first sign-in from the bound
/// tenant</b> and enforced from then on; a later token with the same UPN and tid but a different oid (the UPN
/// re-assigned to another account) is refused until an operator explicitly rebinds. No binding at all ⇒ no role
/// — legacy role rows without a binding are inert until bound.
/// </para>
/// <para>
/// Every non-match is logged at Warning (the one level the worker forwards to App Insights) with the reason,
/// because each one is either an attack signal or an operator action item. Matches are silent.
/// </para>
/// </summary>
public class AdminIdentityBindingService
{
private readonly IAdminRepository _adminRepo;
private readonly IMemoryCache _cache;
private readonly ILogger<AdminIdentityBindingService> _logger;
// Same short per-process TTL as the role caches (see GlobalAdminService): a rebind/removal on one
// scaled-out instance converges on the others within seconds. A single point-read per miss.
private readonly TimeSpan _cacheDuration = TimeSpan.FromSeconds(30);
/// <summary>Cached stand-in for "no binding row" so a negative lookup is not re-read on every request.</summary>
private static readonly AdminIdentityBinding NoBindingSentinel = new();
public AdminIdentityBindingService(
IAdminRepository adminRepo,
IMemoryCache cache,
ILogger<AdminIdentityBindingService> logger)
{
_adminRepo = adminRepo;
_cache = cache;
_logger = logger;
}
/// <summary>
/// True iff the caller's validated identity matches the binding stored for its UPN. Pins the object id on
/// the first matching sign-in from the bound tenant (and then verifies against what was actually stored,
/// so a concurrent pin by another account can never be mistaken for one's own).
/// </summary>
public virtual async Task<bool> IsBoundAsync(AdminIdentity? identity)
{
if (identity == null)
return false;
var binding = await GetCachedBindingAsync(identity.Upn);
if (binding == null)
{
_logger.LogWarning("[IdentityBinding] {Upn} holds no identity binding — no cross-tenant role resolved (tid={TenantId})",
identity.Upn, identity.TenantId);
return false;
}
if (!string.Equals(binding.TenantId, identity.TenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[IdentityBinding] TENANT MISMATCH for {Upn}: token tid={TokenTenantId} bound tid={BoundTenantId} — no cross-tenant role resolved",
identity.Upn, identity.TenantId, binding.TenantId);
return false;
}
if (!binding.IsObjectIdPinned)
{
// First sign-in from the bound tenant: pin. The repository only pins onto an unpinned row homed
// in this tenant and returns the stored row afterwards — verify against THAT, not our intent.
var stored = await _adminRepo.TryPinIdentityObjectIdAsync(identity.Upn, identity.TenantId, identity.ObjectId);
_cache.Remove(CacheKey(identity.Upn));
if (stored == null)
return false;
if (string.Equals(stored.ObjectId, identity.ObjectId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[IdentityBinding] Pinned object id for {Upn} on first sign-in from bound tenant {TenantId}",
identity.Upn, identity.TenantId);
return true;
}
binding = stored;
}
if (!string.Equals(binding.ObjectId, identity.ObjectId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[IdentityBinding] OBJECT-ID MISMATCH for {Upn} in tenant {TenantId}: token oid={TokenObjectId} bound oid={BoundObjectId} — no cross-tenant role resolved (rebind required if the account was legitimately re-created)",
identity.Upn, identity.TenantId, identity.ObjectId, binding.ObjectId);
return false;
}
return true;
}
/// <summary>
/// Creates the binding a grant requires. When a binding already exists it is kept if compatible (same
/// tenant, and the supplied object id — if any — equals the pinned one) and the call is a no-op; an
/// incompatible existing binding throws <see cref="IdentityBindingConflictException"/> so a grant can never
/// silently re-home a UPN — that is an explicit, audited <see cref="RebindAsync"/>.
/// </summary>
public virtual async Task<AdminIdentityBinding> EnsureBoundAsync(string upn, string tenantId, string? objectId, string boundBy)
{
upn = upn.ToLowerInvariant();
tenantId = tenantId.ToLowerInvariant();
objectId = string.IsNullOrWhiteSpace(objectId) ? null : objectId.ToLowerInvariant();
var existing = await _adminRepo.GetIdentityBindingAsync(upn);
if (existing != null)
{
if (!string.Equals(existing.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
throw new IdentityBindingConflictException(
$"{upn} is already bound to tenant {existing.TenantId}; rebind explicitly to move it to {tenantId}");
if (objectId != null && existing.IsObjectIdPinned
&& !string.Equals(existing.ObjectId, objectId, StringComparison.OrdinalIgnoreCase))
throw new IdentityBindingConflictException(
$"{upn} is already pinned to a different object id; rebind explicitly to replace it");
if (objectId == null || existing.IsObjectIdPinned)
return existing;
// Compatible upgrade: same tenant, previously unpinned, object id now supplied.
}
await _adminRepo.UpsertIdentityBindingAsync(upn, tenantId, objectId, boundBy);
_cache.Remove(CacheKey(upn));
return (await _adminRepo.GetIdentityBindingAsync(upn))!;
}
/// <summary>Replaces the binding unconditionally (operator rebind: tenant move, or re-pin after UPN re-assignment).</summary>
public virtual async Task<AdminIdentityBinding> RebindAsync(string upn, string tenantId, string? objectId, string boundBy)
{
upn = upn.ToLowerInvariant();
await _adminRepo.UpsertIdentityBindingAsync(upn, tenantId, objectId, boundBy);
_cache.Remove(CacheKey(upn));
return (await _adminRepo.GetIdentityBindingAsync(upn))!;
}
public virtual async Task RemoveAsync(string upn)
{
upn = upn.ToLowerInvariant();
await _adminRepo.RemoveIdentityBindingAsync(upn);
_cache.Remove(CacheKey(upn));
}
public Task<AdminIdentityBinding?> GetAsync(string upn) => _adminRepo.GetIdentityBindingAsync(upn.ToLowerInvariant());
public Task<List<AdminIdentityBinding>> GetAllAsync() => _adminRepo.GetAllIdentityBindingsAsync();
private async Task<AdminIdentityBinding?> GetCachedBindingAsync(string upn)
{
var key = CacheKey(upn);
if (_cache.TryGetValue<AdminIdentityBinding>(key, out var cached) && cached != null)
return ReferenceEquals(cached, NoBindingSentinel) ? null : cached;
var binding = await _adminRepo.GetIdentityBindingAsync(upn);
_cache.Set(key, binding ?? NoBindingSentinel, _cacheDuration);
return binding;
}
private static string CacheKey(string upn) => $"identity-binding:{upn}";
}
/// <summary>A grant tried to bind a UPN that is already bound to a different tenant / object id (⇒ HTTP 409).</summary>
public sealed class IdentityBindingConflictException : InvalidOperationException
{
public IdentityBindingConflictException(string message) : base(message) { }
}
/// <summary>
/// Row of <see cref="Shared.Constants.TableNames.AdminIdentityBindings"/>: PK = <see cref="Partition"/>, RK = UPN (lowercase).
/// </summary>
public class AdminIdentityBindingEntity : ITableEntity
{
public const string Partition = "Bindings";
public string PartitionKey { get; set; } = Partition;
public string RowKey { get; set; } = string.Empty; // UPN (lowercase)
public DateTimeOffset? Timestamp { get; set; }
public Azure.ETag ETag { get; set; }
/// <summary>The bound UPN (lowercase) — denormalized copy of RowKey.</summary>
public string Upn { get; set; } = string.Empty;
/// <summary>The admin's HOME Entra tenant id (lowercase). Offboarding that tenant purges the row (TenantId property wipe).</summary>
public string TenantId { get; set; } = string.Empty;
/// <summary>The admin's Entra object id (lowercase); empty until pinned.</summary>
public string ObjectId { get; set; } = string.Empty;
/// <summary>UPN of the operator who created/replaced the binding ("Initial Setup" for the seed).</summary>
public string BoundBy { get; set; } = string.Empty;
public DateTime BoundDate { get; set; }
/// <summary>Nullable on purpose: unpinned rows omit it (a default DateTime is below the Edm minimum and would fail the write).</summary>
public DateTime? ObjectIdPinnedDate { get; set; }
}
@@ -0,0 +1,85 @@
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Resolves the Entra identity (home tenant id + object id) behind a UPN at grant time WITHOUT operator
/// input, so granting a cross-tenant role is "type the UPN" and the identity binding is self-maintaining:
/// <list type="number">
/// <item><b>Sign-in history</b> (<see cref="Constants.TableNames.UserActivity"/>): the person has signed in
/// before ⇒ tid + oid are known and verified (both came from validated tokens). Ambiguous history (the same
/// UPN string seen under more than one tenant) is NOT auto-resolved — that is the very situation the binding
/// exists for, and only the operator can say which one is meant.</item>
/// <item><b>UPN domain → onboarded tenant</b>: the person has never signed in, but their UPN domain is the
/// domain of an onboarded tenant ⇒ tid is known; the oid is pinned on their first sign-in from that tenant.</item>
/// </list>
/// Neither ⇒ null; the caller asks the operator to pick the home tenant (the only manual step left, and only
/// for a UPN whose domain the platform has never seen).
/// </summary>
public class AdminIdentityResolver
{
private readonly IMetricsRepository _metricsRepo;
private readonly TenantConfigurationService _tenantConfigService;
private readonly ILogger<AdminIdentityResolver> _logger;
public AdminIdentityResolver(
IMetricsRepository metricsRepo,
TenantConfigurationService tenantConfigService,
ILogger<AdminIdentityResolver> logger)
{
_metricsRepo = metricsRepo;
_tenantConfigService = tenantConfigService;
_logger = logger;
}
public virtual async Task<ResolvedAdminIdentity?> ResolveAsync(string upn)
{
upn = upn.ToLowerInvariant();
var signIns = await _metricsRepo.GetSignInIdentitiesByUpnAsync(upn);
var tenants = signIns.Select(s => s.TenantId.ToLowerInvariant()).Distinct().ToList();
if (tenants.Count == 1)
{
// Most recent sign-in wins for the oid (a re-created account in the same tenant shows up as a
// newer oid; the older one would be refused at sign-in anyway).
var latest = signIns.OrderByDescending(s => s.LastLoginAt).First();
return new ResolvedAdminIdentity(tenants[0],
string.IsNullOrWhiteSpace(latest.ObjectId) ? null : latest.ObjectId.ToLowerInvariant(),
ResolvedAdminIdentity.SourceSignIn);
}
if (tenants.Count > 1)
{
_logger.LogWarning("[IdentityBinding] {Upn} has sign-ins from {Count} different tenants — not auto-resolving the home tenant",
upn, tenants.Count);
return null;
}
var at = upn.LastIndexOf('@');
if (at <= 0 || at == upn.Length - 1)
return null;
var domain = upn[(at + 1)..];
var configs = await _tenantConfigService.GetAllConfigurationsAsync();
var byDomain = configs
.Where(c => !string.IsNullOrWhiteSpace(c.DomainName)
&& string.Equals(c.DomainName.Trim(), domain, StringComparison.OrdinalIgnoreCase))
.Select(c => c.TenantId.ToLowerInvariant())
.Distinct()
.ToList();
if (byDomain.Count == 1)
return new ResolvedAdminIdentity(byDomain[0], null, ResolvedAdminIdentity.SourceDomain);
return null;
}
}
/// <summary>
/// Outcome of <see cref="AdminIdentityResolver.ResolveAsync"/>: the home tenant, the object id when the
/// person has signed in before (null ⇒ pinned on first sign-in), and where the answer came from.
/// </summary>
public sealed record ResolvedAdminIdentity(string TenantId, string? ObjectId, string Source)
{
public const string SourceSignIn = "sign-in";
public const string SourceDomain = "domain";
}
@@ -274,6 +274,7 @@ namespace AutopilotMonitor.Functions.Services
}
ValidateEvaluateOn(rule);
ValidateRelatedDocs(rule);
rule.IsBuiltIn = false;
rule.IsCommunity = false;
@@ -311,6 +312,7 @@ namespace AutopilotMonitor.Functions.Services
}
ValidateEvaluateOn(rule);
ValidateRelatedDocs(rule);
// Custom rules are fully tenant-owned: fold a notify override into the row default
// (there is no separate RuleState for them) — mirrors how the UI edits Enabled.
@@ -656,6 +658,43 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Related-doc URLs are author-controlled (Tenant Admin) and rendered as anchor hrefs
/// for every viewer of a fired rule, including cross-tenant Global/MSP admins. Only
/// absolute http(s) URLs may persist — a stored javascript:/data: URL would be a durable
/// stored XSS in the portal origin. Entries with an empty URL (the editor's "+ Add Link"
/// default) are dropped rather than rejected. Throws <see cref="ArgumentException"/>
/// (mapped to 400 by the function) so the author sees the offending entry.
/// </summary>
internal static void ValidateRelatedDocs(AnalyzeRule rule)
{
if (rule.RelatedDocs is not { Count: > 0 })
return;
rule.RelatedDocs = rule.RelatedDocs
.Where(d => d != null && !string.IsNullOrWhiteSpace(d.Url))
.ToList();
var invalid = rule.RelatedDocs
.Where(d => !IsAbsoluteHttpUrl(d.Url))
.Select(d => d.Url.Trim())
.ToList();
if (invalid.Count > 0)
{
throw new ArgumentException(
$"Invalid relatedDocs URL(s): {string.Join(", ", invalid)}. " +
"Only absolute http:// or https:// URLs are allowed.");
}
}
internal static bool IsAbsoluteHttpUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return false;
return Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
/// <summary>
/// True when two analyze rule definitions are the same content (ignoring provenance / tenant
/// state / timestamps) — the seed's "did the definition change?" test. Also used by the
@@ -15,6 +15,12 @@ namespace AutopilotMonitor.Functions.Services
/// mis-conversion shifts only the IME side and lands on the 15-minute offset grid.
/// Replayed old log lines produce large OFF-grid deltas — the grid-residual test is what
/// separates the two (field measurement 2026-08-20: without it, 48 instead of 26 true hits).
/// The median alone is not enough, though: when a dead/relaunched agent re-tails the IME log,
/// one upload burst can carry the majority of the session's IME samples with a CONTINUUM of
/// ages (field 2026-08-28, session d832560a: 508 of 627 samples, 46…733 min) whose median
/// lands on the grid by chance. A genuine zone mis-conversion shifts every line by the same
/// grid multiple, so the grid-conformity test is applied per sample as well: the session
/// only counts when the bulk of the IME deltas individually sit on the grid.
///
/// Goal state: this never fires. Any hit is either a case the per-line anchoring does not
/// cover or a detector bug — both are actionable findings, never noise to tune away.
@@ -30,6 +36,11 @@ namespace AutopilotMonitor.Functions.Services
// ReceivedAt is stamped per upload batch, not per event; a side backed by fewer
// distinct batches yields a batch-composition artifact, not a session property.
internal const int MinDistinctBatchesPerSide = 3;
// Share of IME samples whose own delta (relative to the other side's median) must sit
// within ResidualToleranceMinutes of SOME grid multiple (zero included). A replay
// continuum hits that window only ~2·2/15 ≈ 27 % of the time by chance; a real skew,
// even one mixing two writer eras, hits it for essentially every line.
internal const double MinGridConformantFraction = 0.8;
internal sealed class Result
{
@@ -42,6 +53,7 @@ namespace AutopilotMonitor.Functions.Services
public int OtherSampleCount { get; set; }
public int ImeBatchCount { get; set; }
public int OtherBatchCount { get; set; }
public double GridConformantFraction { get; set; }
}
/// <summary>
@@ -67,6 +79,10 @@ namespace AutopilotMonitor.Functions.Services
if (Math.Abs(steps) < 1 || residual >= ResidualToleranceMinutes)
return null;
var conformant = ComputeGridConformantFraction(scan.ImeDeltaMinutes, medianOther);
if (conformant < MinGridConformantFraction)
return null;
return new Result
{
MedianImeDeltaMinutes = medianIme,
@@ -78,9 +94,31 @@ namespace AutopilotMonitor.Functions.Services
OtherSampleCount = scan.OtherDeltaMinutes.Count,
ImeBatchCount = scan.ImeDistinctBatchCount,
OtherBatchCount = scan.OtherDistinctBatchCount,
GridConformantFraction = conformant,
};
}
/// <summary>
/// Fraction of samples whose delta minus <paramref name="reference"/> lies within
/// <see cref="ResidualToleranceMinutes"/> of any multiple of <see cref="GridMinutes"/>
/// (zero included).
/// </summary>
internal static double ComputeGridConformantFraction(IReadOnlyList<double> deltas, double reference)
{
if (deltas.Count == 0)
return 0;
int hits = 0;
foreach (var delta in deltas)
{
var rel = delta - reference;
var nearest = Math.Round(rel / GridMinutes, MidpointRounding.AwayFromZero) * GridMinutes;
if (Math.Abs(rel - nearest) < ResidualToleranceMinutes)
hits++;
}
return (double)hits / deltas.Count;
}
/// <summary>
/// True when the session's IME events are dominated by writer-declared ("bias")
/// offsets — a bias offset comes verbatim from the log line's own zone marker and
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Azure.Data.Tables;
@@ -12,15 +13,18 @@ namespace AutopilotMonitor.Functions.Services;
/// SUBSET of tenants: exactly the tenants for which it holds an Active, enabled DelegatedAdmins row.
/// Externally this is surfaced as "MSP mode".
///
/// The scope is keyed on UPN; assignment RESOLUTION is tid-agnostic (identical to <see cref="GlobalAdminService"/>),
/// but the effective scope is GATED on the caller's home tenant (JWT tid) being Pro — the MSP
/// seat is a Pro capability of the tenant the admin is homed in, while the managed targets may
/// be any edition. Resolution is cached briefly (UPN-keyed, gate applied per call); every mutation
/// invalidates the cache.
/// Assignment rows are keyed on UPN, but RESOLUTION is keyed on the caller's full <see cref="AdminIdentity"/>
/// (identical to <see cref="GlobalAdminService"/>): the resolved rows confer scope only when the caller's
/// validated JWT tid + oid match the UPN's identity binding (<see cref="AdminIdentityBindingService"/>) — a
/// foreign-tenant token carrying a matching UPN string resolves an empty scope. The effective scope is then
/// GATED on the caller's home tenant (JWT tid) being Pro — the MSP seat is a Pro capability of the tenant
/// the admin is homed in, while the managed targets may be any edition. Row resolution is cached briefly
/// (UPN-keyed; binding + gate applied per call); every mutation invalidates the cache.
/// </summary>
public class DelegatedAdminService
{
private readonly IAdminRepository _adminRepo;
private readonly AdminIdentityBindingService _bindings;
private readonly TenantEntitlementService _entitlementService;
private readonly IMemoryCache _cache;
private readonly ILogger<DelegatedAdminService> _logger;
@@ -33,11 +37,13 @@ public class DelegatedAdminService
public DelegatedAdminService(
IAdminRepository adminRepo,
AdminIdentityBindingService bindings,
TenantEntitlementService entitlementService,
IMemoryCache cache,
ILogger<DelegatedAdminService> logger)
{
_adminRepo = adminRepo;
_bindings = bindings;
_entitlementService = entitlementService;
_cache = cache;
_logger = logger;
@@ -47,27 +53,43 @@ public class DelegatedAdminService
/// Resolves the caller's effective delegated scope: the set of tenants it may access and the role per
/// tenant. Only <see cref="Constants.DelegatedStatus.Active"/> + enabled rows with a recognized role
/// contribute; pending/revoked/disabled/unknown-role rows are ignored (fail-closed). Cached briefly (see _cacheDuration).
/// Returns an empty (never null) scope for a UPN with no effective assignments.
/// Returns an empty (never null) scope for a caller with no effective assignments — including a caller
/// whose identity is incomplete (null) or whose tid/oid do not match the UPN's identity binding.
/// <para>
/// <b>Entitlement gate (single choke-point for ALL delegated-scope consumers):</b> delegated ("MSP")
/// access is a Pro capability of the caller's HOME tenant — the tenant that pays for the MSP
/// seat (<paramref name="callerHomeTenantId"/> = the validated JWT tid, unforgeable). The MANAGED
/// seat (<see cref="AdminIdentity.TenantId"/> = the validated JWT tid, unforgeable). The MANAGED
/// (target) tenants may be any edition — an MSP on Pro may manage Community customers.
/// Null/empty home tid or a non-Pro home tenant ⇒ empty scope (fail-closed). Grant rows stay
/// A non-Pro home tenant ⇒ empty scope (fail-closed). Grant rows stay
/// untouched; they become inert and resurrect when the home tenant upgrades / trials.
/// The gate sits AFTER the cache read so the cached scope stays pure (keyed on UPN only) and a
/// home-tenant edition change converges within the entitlement service's own config-cache TTL.
/// Binding check and gate sit AFTER the cache read so the cached scope stays pure (keyed on UPN only)
/// and a rebind / home-tenant edition change converges within the respective cache TTLs.
/// </para>
/// </summary>
public virtual async Task<DelegatedScope> GetScopeAsync(string? upn, string? callerHomeTenantId = null)
public virtual async Task<DelegatedScope> GetScopeAsync(AdminIdentity? identity)
{
if (string.IsNullOrWhiteSpace(upn))
if (identity == null)
return DelegatedScope.Empty;
upn = upn.ToLowerInvariant();
var scope = await GetRowScopeAsync(identity.Upn);
if (scope.IsEmpty)
return scope;
// Identity binding BEFORE the entitlement gate: an unbound/mismatched caller must not even trigger
// a home-tenant edition lookup, and the mismatch Warning is the more important signal.
if (!await _bindings.IsBoundAsync(identity))
return DelegatedScope.Empty;
return await ApplyHomeTenantGateAsync(scope, identity.Upn, identity.TenantId);
}
/// <summary>The scope the assignment ROWS (direct + group-derived) confer on a UPN, ignoring identity binding
/// and entitlement. Cached briefly (UPN-keyed).</summary>
private async Task<DelegatedScope> GetRowScopeAsync(string upn)
{
var cacheKey = $"delegated-scope:{upn}";
if (_cache.TryGetValue<DelegatedScope>(cacheKey, out var cached) && cached != null)
return await ApplyHomeTenantGateAsync(cached, upn, callerHomeTenantId);
return cached;
var rows = await _adminRepo.GetDelegatedTenantsAsync(upn);
var tenantRoles = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -121,7 +143,7 @@ public class DelegatedAdminService
// service), which empties the scope. Grant rows stay untouched — inert until upgrade/trial.
var scope = new DelegatedScope(tenantRoles);
_cache.Set(cacheKey, scope, _cacheDuration);
return await ApplyHomeTenantGateAsync(scope, upn, callerHomeTenantId);
return scope;
}
/// <summary>
@@ -129,28 +151,32 @@ public class DelegatedAdminService
/// every cache read/write so the cached scope stays pure (UPN-keyed) — the edition lookup has
/// its own 5-minute config cache, so this adds no per-request storage I/O in the steady state.
/// </summary>
private async Task<DelegatedScope> ApplyHomeTenantGateAsync(DelegatedScope scope, string upn, string? callerHomeTenantId)
private async Task<DelegatedScope> ApplyHomeTenantGateAsync(DelegatedScope scope, string upn, string callerHomeTenantId)
{
if (scope.IsEmpty)
return scope;
if (await _entitlementService.GetEditionAsync(callerHomeTenantId) == Security.TenantEdition.Pro)
return scope;
_logger.LogInformation(
"[DelegatedAdmin] Suppressing delegated scope for {Upn} — home tenant {HomeTenantId} is not Pro (MSP seat requires the Pro plan)",
upn, string.IsNullOrWhiteSpace(callerHomeTenantId) ? "(unknown)" : callerHomeTenantId);
upn, callerHomeTenantId);
return DelegatedScope.Empty;
}
/// <summary>Creates or replaces an assignment, then invalidates the UPN's cached scope.</summary>
/// <summary>
/// Creates or replaces an assignment, then invalidates the UPN's cached scope. Binds the UPN to its home
/// tenant (and object id, when known) FIRST — an assignment without a binding is inert, and a binding
/// conflict (UPN already homed elsewhere) aborts before any row is written.
/// </summary>
/// <exception cref="IdentityBindingConflictException">The UPN is already bound to a different identity.</exception>
public async Task<DelegatedAdminEntry> UpsertAsync(
string upn, string tenantId, string role, string status, string source, string grantedBy)
string upn, string tenantId, string role, string status, string source, string grantedBy,
string homeTenantId, string? objectId)
{
upn = upn.ToLowerInvariant();
tenantId = tenantId.ToLowerInvariant();
grantedBy = grantedBy.ToLowerInvariant();
await _bindings.EnsureBoundAsync(upn, homeTenantId, objectId, grantedBy);
await _adminRepo.UpsertDelegatedAdminAsync(upn, tenantId, role, status, source, grantedBy);
Invalidate(upn);
@@ -286,13 +312,17 @@ public class DelegatedAdminService
/// not exist (meta-backed) — so the service fully owns the existence invariant, independent of any caller
/// pre-check, and a delete racing an assign can't leave a dangling assignment row.
/// </summary>
public async Task<bool> AssignGroupAsync(string upn, string groupId, string role, bool isEnabled, string assignedBy)
/// <exception cref="IdentityBindingConflictException">The UPN is already bound to a different identity.</exception>
public async Task<bool> AssignGroupAsync(
string upn, string groupId, string role, bool isEnabled, string assignedBy, string homeTenantId, string? objectId)
{
groupId = NormalizeGroupId(groupId);
if (await _adminRepo.GetTenantGroupAsync(groupId) == null)
return false;
upn = upn.ToLowerInvariant();
// Binding first (see UpsertAsync): an assignment without a binding is inert; a conflict aborts.
await _bindings.EnsureBoundAsync(upn, homeTenantId, objectId, assignedBy);
var ok = await _adminRepo.AssignGroupAsync(upn, groupId, role, isEnabled, assignedBy);
Invalidate(upn);
return ok;
@@ -0,0 +1,60 @@
using System;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// "Can an on-demand diagnostics upload succeed for this tenant?" — the single definition
/// behind the session-detail Collect Logs button (feature flag
/// <c>diagnosticsUploadConfigured</c>) and the ops-event tripwire that reports when a
/// tenant admin turns that capability on or off (portal Settings, the Collect Logs
/// quick-config dialog, MCP <c>update_tenant_config</c>, or a config revert).
/// </summary>
public sealed class DiagnosticsUploadConfigChange
{
public bool Enabled { get; }
public string? Destination { get; }
public string Mode { get; }
private DiagnosticsUploadConfigChange(bool enabled, string? destination, string mode)
{
Enabled = enabled;
Destination = destination;
Mode = mode;
}
/// <summary>
/// Mode not Off AND a usable destination (customer SAS or Hosted). Mirrors the agent's
/// own gate (<c>DiagnosticsUploadMode</c> + <c>DiagnosticsUploadEnabled</c>), so
/// "configured" here means the agent would actually build and upload a package.
/// </summary>
public static bool IsConfigured(TenantConfiguration? config)
{
if (config == null) return false;
return !string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase)
&& GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(
config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination);
}
/// <summary>
/// Returns the flip when <paramref name="after"/> changes the configured state relative
/// to <paramref name="before"/>; null when the capability did not change (including
/// destination/mode edits that keep it on). A missing <paramref name="before"/> row
/// counts as "not configured".
/// </summary>
public static DiagnosticsUploadConfigChange? Detect(TenantConfiguration? before, TenantConfiguration after)
{
if (after == null) throw new ArgumentNullException(nameof(after));
var wasConfigured = IsConfigured(before);
var isConfigured = IsConfigured(after);
if (wasConfigured == isConfigured) return null;
return new DiagnosticsUploadConfigChange(
isConfigured,
after.DiagnosticsUploadDestination,
after.DiagnosticsUploadMode ?? "Off");
}
}
}
@@ -61,18 +61,18 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
}
/// <inheritdoc />
public async Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default)
public async Task<bool> SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogDebug("{ConfigKey} not configured — skipping preview approval email", ApiKeyConfigKey);
return;
_logger.LogWarning("{ConfigKey} not configured — skipping preview approval email", ApiKeyConfigKey);
return false;
}
if (string.IsNullOrWhiteSpace(toEmail))
{
_logger.LogDebug("No notification email set — skipping preview approval email for {Domain}", domainName);
return;
return false;
}
var sent = await SendViaMandrillAsync(
@@ -88,6 +88,8 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
"Preview approval email sent to {ToEmail} for domain {Domain}",
toEmail, domainName);
}
return sent;
}
/// <summary>
@@ -99,7 +99,9 @@ namespace AutopilotMonitor.Functions.Services
bool HelloPolicyDisabled,
bool SkipUserEsp,
bool RealmJoinDetected,
bool RealmJoinResolved);
bool RealmJoinResolved,
bool HasAppInstallFailure,
bool HasAgentMaxLifetimeTimeout);
/// <summary>
/// Walk a session's events and distill the ESP rollup. Tolerant of missing/empty input
@@ -115,6 +117,7 @@ namespace AutopilotMonitor.Functions.Services
int acctBestN = 0, acctBestM = 0;
bool acctFallbackAll = false;
bool hasFailure = false, hasComplete = false, hasEnrollmentComplete = false, hasEmergencyBreak = false;
bool hasAppInstallFailure = false, hasMaxLifetimeTimeout = false;
bool desktopArrived = false, helloResolved = false;
bool realmJoinDetected = false, realmJoinResolved = false;
bool sawHelloPolicyEnabled = false, sawHelloPolicyDisabled = false;
@@ -134,7 +137,19 @@ namespace AutopilotMonitor.Functions.Services
// (misclassification audit 2026-07-16, tenant a53e67ec cluster).
if (!Eq(TryGetDataString(evt, "failureType") ?? string.Empty, "agent_timeout"))
hasFailure = true;
else
hasMaxLifetimeTimeout = true;
}
// The V2 watchdog shape of the same fact: the agent shut itself down at its
// max-lifetime cap. Either shape proves the agent is gone for good — rule 5 uses
// that to skip the AwaitingUser grace (a wait nothing can ever end).
else if (Eq(type, "agent_shutting_down"))
{
if (Eq(TryGetDataString(evt, "reason") ?? string.Empty, "max_lifetime"))
hasMaxLifetimeTimeout = true;
}
else if (Eq(type, "app_install_failed"))
hasAppInstallFailure = true;
else if (Eq(type, "enrollment_complete") || Eq(type, "whiteglove_complete"))
{
hasComplete = true;
@@ -230,7 +245,9 @@ namespace AutopilotMonitor.Functions.Services
HelloPolicyDisabled: sawHelloPolicyDisabled && !sawHelloPolicyEnabled,
SkipUserEsp: sawSkipUserTrue && !sawSkipUserFalse,
RealmJoinDetected: realmJoinDetected,
RealmJoinResolved: realmJoinResolved);
RealmJoinResolved: realmJoinResolved,
HasAppInstallFailure: hasAppInstallFailure,
HasAgentMaxLifetimeTimeout: hasMaxLifetimeTimeout);
}
/// <summary>
@@ -418,7 +435,13 @@ namespace AutopilotMonitor.Functions.Services
if (rollup.DeviceSetupAllSucceeded)
{
var elapsedHours = (nowUtc - startedAtUtc).TotalHours;
if (elapsedHours < graceHours)
// The grace only buys time for a LIVE agent to come back with real evidence. Once
// the agent's own max-lifetime watchdog has fired it has cleaned up and exited —
// parking such a session AwaitingUser is a countdown, not a wait (all 5 observed
// maxlife:r5_awaiting episodes expired unhealed; calibration read 2026-08-27), so
// it skips straight to the terminal fork. Nothing is lost by deciding now: late
// straggler telemetry still heals a terminal verdict (TryLateTelemetryReconcileAsync).
if (elapsedHours < graceHours && !rollup.HasAgentMaxLifetimeTimeout)
{
var acct = rollup.AccountSetupTotal > 0
? $" (Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal})"
@@ -428,11 +451,32 @@ namespace AutopilotMonitor.Functions.Services
ClassifierRules.R5DeviceSetupAwaiting);
}
// 5a. Desktop reached + clean app record → completed (assumed). Unlike rule 4 this
// has no Hello terminal, so it never fires as an IMMEDIATE completion signal —
// only here, after the full grace (or with the agent provably gone), where
// "the user phase is still running" is no longer a possible explanation.
// Calibration justified the assumption (2026-08-27 read): r5-Incomplete devices
// re-enrolled at 2.6 % — the succeeded background, not the 29 % failure level —
// and the sampled half with a desktop had all tracked apps terminal with none
// failed. An app_install_failed anywhere disqualifies: "completed" must not
// overclaim a session whose ESP payload is provably not clean.
if (rollup.DesktopArrived && !rollup.HasAppInstallFailure)
return (SessionStatus.Succeeded, AppendReconcileTiming(
"Reconciled: Device Setup fully provisioned and a real-user desktop was observed — " +
"no completion, failure or app-install error before the agent went silent; treating the enrollment as completed",
startedAtUtc, nowUtc, lastEventAtUtc), ClassifierRules.R5DesktopAssumed);
// "0/0" would suggest a parsed rollup that never existed — when no Account Setup
// rollup was ever observed, say so instead (misclassification audit 2026-07-16).
var acctDetail = rollup.AccountSetupTotal > 0
? $"last Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal}"
: "Account Setup progress never observed";
// Inside the grace the only way here is the max-lifetime short-circuit — the
// "within {grace}h" wording would be false, so name the watchdog instead.
if (elapsedHours < graceHours)
return (SessionStatus.Incomplete,
$"Agent max-lifetime watchdog fired — agent gone without completion after Device Setup completed ({acctDetail})",
ClassifierRules.R5DeviceSetupIncomplete);
return (SessionStatus.Incomplete,
$"No completion signal within {graceHours}h grace after Device Setup completed ({acctDetail})",
ClassifierRules.R5DeviceSetupIncomplete);
@@ -285,6 +285,12 @@ namespace AutopilotMonitor.Functions.Services
// Part-1 start (same anchor the maintenance sweep uses).
var effectiveStart = session?.ResumedAt ?? session?.StartedAt ?? triggerEvent.Timestamp;
// The trigger event itself may not be persisted yet when this read runs — append it so
// the rollup always carries the max-lifetime fact (rule 5 skips the AwaitingUser grace
// on it: the agent is provably gone). The rollup folds booleans, so a stored duplicate
// is harmless.
sessionEvents.Add(triggerEvent);
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
var (targetStatus, reason, rule) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, effectiveStart, now, graceHours, session?.LastEventAt ?? c.LatestEventTimestamp,
@@ -1,5 +1,6 @@
using System.Linq;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Ime;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Functions.Services.Vulnerability;
using AutopilotMonitor.Shared.DataAccess;
@@ -155,23 +156,27 @@ namespace AutopilotMonitor.Functions.Services
_logger.LogWarning(t.Exception?.InnerException,
"ImeVersionHistory update failed (non-fatal)");
}
else if (t.Result)
else if (t.Result?.IsNew == true)
{
await _opsEventService.RecordNewImeVersionDetectedAsync(
imeVersion, request.TenantId, request.SessionId);
// First fleet-wide sighting → archive the installer binary while
// Microsoft's versionless CDN still serves exactly this build.
// First fleet-wide sighting → archive the installer binary. The
// archiver verifies the package's ProductVersion against every
// candidate host, so a first sighting without a CSP URL is fine.
// Producer is fail-soft; worker pauses on ImeMsiArchivingEnabled=false.
await _imeMsiArchiveProducer.EnqueueAsync(new ImeMsiArchiveEnvelope
{
Version = imeVersion,
MsiDownloadUrl = msiDownloadUrl,
MsiMatchedBy = msiMatchedBy,
TenantId = request.TenantId,
SessionId = request.SessionId,
EnqueuedAt = DateTime.UtcNow,
});
await EnqueueImeMsiArchiveAsync(imeVersion, msiDownloadUrl, msiMatchedBy, request);
}
else if (ImeMsiArchiver.ShouldRequeueOnSighting(t.Result, msiDownloadUrl, msiMatchedBy, DateTime.UtcNow))
{
// Known but not archived (no host served the build at first sighting,
// or the version predates the archiver) and THIS session brings a
// version-authoritative CSP URL → one more try, at most once per
// backoff window. The Queued stamp is what other sessions of the
// same version see, so they don't pile on.
await _sessionRepo.UpdateImeVersionArchiveInfoAsync(
imeVersion, ImeMsiArchiver.Statuses.Queued, null, null, null, null);
await EnqueueImeMsiArchiveAsync(imeVersion, msiDownloadUrl, msiMatchedBy, request);
}
}, TaskScheduler.Default);
}
@@ -496,6 +501,23 @@ namespace AutopilotMonitor.Functions.Services
/// never match (net_total_latency_ms absent) — no fallback, fleet turns over
/// per-enrollment. Exposed as internal for unit testing.
/// </summary>
/// <summary>
/// Fire-and-forget enqueue of the installer-archive job for an IME version; the
/// producer is fail-soft (a lost message means "not auto-archived", the /ime-decompile
/// skill's blob backfill is the manual fallback).
/// </summary>
private Task EnqueueImeMsiArchiveAsync(
string imeVersion, string? msiDownloadUrl, string? msiMatchedBy, IngestEventsRequest request)
=> _imeMsiArchiveProducer.EnqueueAsync(new ImeMsiArchiveEnvelope
{
Version = imeVersion,
MsiDownloadUrl = msiDownloadUrl,
MsiMatchedBy = msiMatchedBy,
TenantId = request.TenantId,
SessionId = request.SessionId,
EnqueuedAt = DateTime.UtcNow,
});
internal static bool TryComputeSessionApiLatency(
List<EnrollmentEvent> events, out double avgLatencyMs, out int requestCount)
{
@@ -596,7 +618,7 @@ namespace AutopilotMonitor.Functions.Services
histogram.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Key} {kv.Value}"));
var message =
$"IME-derived events skewed {result.DiffMinutes:+0.0;-0.0} min vs other sources " +
$"({result.GridSteps}x15min grid, residual {result.ResidualMinutes:0.0} min; " +
$"({result.GridSteps}x15min grid, residual {result.ResidualMinutes:0.0} min, {result.GridConformantFraction:P0} of IME samples on grid; " +
$"median dIME {result.MedianImeDeltaMinutes:0.0} / dOther {result.MedianOtherDeltaMinutes:0.0} min; " +
$"{result.ImeSampleCount}/{result.OtherSampleCount} samples over {result.ImeBatchCount}/{result.OtherBatchCount} batches; " +
$"origins: {(origins.Length > 0 ? origins : "none")})";
@@ -608,6 +630,7 @@ namespace AutopilotMonitor.Functions.Services
diffMinutes = Math.Round(result.DiffMinutes, 2),
gridSteps = result.GridSteps,
residualMinutes = Math.Round(result.ResidualMinutes, 2),
gridConformantFraction = Math.Round(result.GridConformantFraction, 3),
medianImeDeltaMinutes = Math.Round(result.MedianImeDeltaMinutes, 2),
medianOtherDeltaMinutes = Math.Round(result.MedianOtherDeltaMinutes, 2),
imeSampleCount = result.ImeSampleCount,
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Azure.Data.Tables;
@@ -8,11 +9,19 @@ namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Service for managing Global Admin permissions
/// Global Admins can access cross-tenant data and perform platform-wide operations
/// Global Admins can access cross-tenant data and perform platform-wide operations.
/// <para>
/// Role RESOLUTION is keyed on the caller's full <see cref="AdminIdentity"/>, never on the UPN string alone:
/// the GlobalAdmins row is looked up by UPN, but it confers the role only when the caller's validated JWT
/// tid + oid match the UPN's <see cref="AdminIdentityBinding"/> (see <see cref="AdminIdentityBindingService"/>).
/// The API accepts tokens from any Entra tenant, and upn/preferred_username are mutable — a foreign-tenant
/// token with a matching UPN must resolve to no role.
/// </para>
/// </summary>
public class GlobalAdminService
{
private readonly IAdminRepository _adminRepo;
private readonly AdminIdentityBindingService _bindings;
private readonly IMemoryCache _cache;
private readonly ILogger<GlobalAdminService> _logger;
// Per-process cache: on scaled-out Flex Consumption, the _cache.Remove on add/remove/disable
@@ -28,42 +37,51 @@ public class GlobalAdminService
public GlobalAdminService(
IAdminRepository adminRepo,
AdminIdentityBindingService bindings,
IMemoryCache cache,
ILogger<GlobalAdminService> logger)
{
_adminRepo = adminRepo;
_bindings = bindings;
_cache = cache;
_logger = logger;
}
/// <summary>
/// Checks if a UPN is a Global Admin
/// Uses caching for performance
/// Checks whether the caller is a Global Admin (the GlobalAdmin platform role, identity-bound).
/// </summary>
/// <param name="upn">User Principal Name (e.g., oliver@contoso.com)</param>
/// <returns>True if the user is a Global Admin</returns>
public virtual async Task<bool> IsGlobalAdminAsync(string? upn)
/// <param name="identity">The caller's validated identity; null (missing upn/tid/oid) ⇒ false.</param>
public virtual async Task<bool> IsGlobalAdminAsync(AdminIdentity? identity)
{
// Single source of truth: GlobalAdmin == the GlobalAdmin platform role.
return await GetGlobalRoleAsync(upn) == Constants.GlobalRoles.GlobalAdmin;
return await GetGlobalRoleAsync(identity) == Constants.GlobalRoles.GlobalAdmin;
}
/// <summary>
/// Resolves the caller's platform role from the GlobalAdmins table.
/// Returns <see cref="Constants.GlobalRoles.GlobalAdmin"/>, <see cref="Constants.GlobalRoles.GlobalReader"/>,
/// or <c>null</c> when the UPN has no enabled GlobalAdmins row. Cached briefly (see _cacheDuration).
/// Resolves the caller's platform role: <see cref="Constants.GlobalRoles.GlobalAdmin"/>,
/// <see cref="Constants.GlobalRoles.GlobalReader"/>, or <c>null</c> when the UPN has no enabled
/// GlobalAdmins row OR the caller's tid/oid do not match the UPN's identity binding (fail-closed —
/// an unbound row is inert). The row lookup is cached briefly (see _cacheDuration); the binding check
/// runs only for UPNs that actually hold a row, so ordinary tenant users cost one cached read.
/// </summary>
public virtual async Task<string?> GetGlobalRoleAsync(string? upn)
public virtual async Task<string?> GetGlobalRoleAsync(AdminIdentity? identity)
{
if (string.IsNullOrWhiteSpace(upn))
if (identity == null)
{
_logger.LogDebug("GetGlobalRoleAsync: UPN is null or empty");
_logger.LogDebug("GetGlobalRoleAsync: incomplete caller identity (upn/tid/oid)");
return null;
}
// Normalize UPN to lowercase for case-insensitive comparison
upn = upn.ToLowerInvariant();
var role = await GetRowRoleAsync(identity.Upn);
if (role == null)
return null;
return await _bindings.IsBoundAsync(identity) ? role : null;
}
/// <summary>The role the GlobalAdmins ROW carries for a UPN, ignoring identity binding. Cached briefly.</summary>
private async Task<string?> GetRowRoleAsync(string upn)
{
var cacheKey = $"global-role:{upn}";
if (_cache.TryGetValue<string>(cacheKey, out var cached) && cached != null)
{
@@ -82,15 +100,21 @@ public class GlobalAdminService
}
/// <summary>
/// Adds a user as a Global Admin
/// Adds a user as a Global Admin, binding the UPN to its home tenant (and object id, when known) FIRST —
/// a role row without a binding is inert, and a binding conflict (UPN already homed elsewhere) aborts
/// before any row is written.
/// </summary>
/// <param name="upn">User Principal Name</param>
/// <param name="addedBy">UPN of the admin who is adding this user</param>
public async Task<GlobalAdminEntity> AddGlobalAdminAsync(string upn, string addedBy)
/// <param name="homeTenantId">The Entra tenant the person signs in from (JWT tid).</param>
/// <param name="objectId">The person's Entra object id, or null to pin it on their first sign-in.</param>
/// <exception cref="IdentityBindingConflictException">The UPN is already bound to a different identity.</exception>
public async Task<GlobalAdminEntity> AddGlobalAdminAsync(string upn, string addedBy, string homeTenantId, string? objectId)
{
upn = upn.ToLowerInvariant();
addedBy = addedBy.ToLowerInvariant();
await _bindings.EnsureBoundAsync(upn, homeTenantId, objectId, addedBy);
await _adminRepo.AddGlobalAdminAsync(upn, addedBy);
// Invalidate cache
@@ -14,8 +14,13 @@ public interface IEmailService
/// <summary>
/// Sends the tenant-activation welcome email. No-op when the provider is not configured
/// or the recipient is empty.
/// <para>
/// Returns true only when the provider accepted the message. Callers record that outcome
/// as an ops event: the send is best-effort, so a false here is the ONLY signal that a
/// customer never got their welcome mail.
/// </para>
/// </summary>
Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default);
Task<bool> SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default);
/// <summary>
/// Operator test send: the effective template of <paramref name="kind"/> (or an unsaved
@@ -1,6 +1,13 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Azure;
@@ -10,13 +17,7 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace AutopilotMonitor.Functions.Services.Ime
{
/// <summary>
/// Outcome of an IME-installer archive attempt. <see cref="Status"/> is one of the
/// <see cref="ImeMsiArchiver.Statuses"/> constants and is merged verbatim onto the
/// ImeVersionHistory row. <see cref="Retryable"/> tells the queue worker whether to
/// trigger a visibility-timeout retry (transient download problems) or complete the
/// message (permanent rejections).
/// </summary>
/// <summary>Outcome of one archive attempt. Statuses are persisted verbatim on the ImeVersionHistory row.</summary>
public sealed record ImeMsiArchiveResult(
bool Success, string Status, bool Retryable,
string? BlobPath, string? Sha256, long? SizeBytes, string? SourceUrl);
@@ -29,14 +30,24 @@ namespace AutopilotMonitor.Functions.Services.Ime
/// fail-soft by contract (never throws), Statuses constants persisted verbatim,
/// admin-configured size cap, typed catch ladder, <c>protected virtual</c> test seams.
/// <para>
/// The download URL comes from agent telemetry and is therefore untrusted: only HTTPS
/// URLs on <c>*.manage.microsoft.com</c> whose path filename is exactly
/// <c>IntuneWindowsAgent.msi</c> are accepted; anything else falls back to the canonical
/// versionless CDN URL — safe for a NEW version, because "new" is by definition what
/// that URL currently serves. The blob write uses If-None-Match:* so a queue re-delivery
/// can never overwrite an existing archive entry; the 409 path additionally heals a
/// provenance sidecar the first attempt failed to write (see
/// <see cref="CompleteExistingArchiveAsync"/>).
/// <b>The blob folder is named after the version the fleet OBSERVED, so the bytes inside
/// must be that version.</b> Microsoft serves IME from several versionless CDN hosts that
/// carry different rollout rings at the same time (2026-08-29: <c>imeswdb-afd-secondary</c>
/// served 1.104.102.0 while <c>imeswda-afd-primary</c> served 1.105.101.0 and
/// <c>imeswda-afd-hotfix</c> 1.105.103.0), so no URL is trusted blindly: every candidate
/// download is spooled to a temp file, its <c>ProductVersion</c> is read out of the MSI
/// (<see cref="MsiProductVersionReader"/>) and only a matching package is uploaded.
/// Candidates are tried in order — the agent-reported CSP URL first (untrusted input:
/// only HTTPS on <c>*.manage.microsoft.com</c> with the exact installer filename passes
/// the allowlist), then the known distribution hosts. When none serves the observed
/// version the attempt ends as <see cref="Statuses.FailedVersionMismatch"/>; a later
/// sighting that carries a version-authoritative URL re-queues it
/// (<see cref="ShouldRequeueOnSighting"/>).
/// </para>
/// <para>
/// The blob write uses If-None-Match:* so a queue re-delivery can never overwrite an
/// existing archive entry; the 409 path additionally heals a provenance sidecar the
/// first attempt failed to write (see <see cref="CompleteExistingArchiveAsync"/>).
/// </para>
/// </summary>
public class ImeMsiArchiver
@@ -45,23 +56,43 @@ namespace AutopilotMonitor.Functions.Services.Ime
public static class Statuses
{
public const string Archived = "Archived";
/// <summary>Set by the ingest path when a later sighting re-queues the archive job.</summary>
public const string Queued = "Queued";
public const string FailedBadVersion = "Failed:BadVersion";
public const string FailedTooLarge = "Failed:TooLarge";
public const string FailedDownload = "Failed:Download";
public const string FailedTimeout = "Failed:Timeout";
public const string FailedError = "Failed:Error";
/// <summary>No candidate URL served a package whose ProductVersion equals the observed version.</summary>
public const string FailedVersionMismatch = "Failed:VersionMismatch";
}
/// <summary>
/// The CSP's versionless distribution endpoint — always serves the currently rolled-out
/// build. Fallback when the event carried no (acceptable) URL.
/// Microsoft's versionless IME distribution hosts, tried in this order after the
/// event-reported URL. Each host carries its own rollout ring, so the same URL shape
/// serves different builds at the same time — the version check decides, not the order.
/// </summary>
public const string CanonicalMsiUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
public static readonly IReadOnlyList<string> FallbackMsiUrls = new[]
{
"https://imeswda-afd-primary.manage.microsoft.com/IntuneWindowsAgent.msi",
"https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi",
"https://imeswda-afd-hotfix.manage.microsoft.com/IntuneWindowsAgent.msi",
};
/// <summary>
/// Minimum gap between archive attempts triggered by later sightings of a version that
/// is not archived yet. Bounds the worst case (a version no host serves any more) to
/// one download per host per day instead of one per enrollment session.
/// </summary>
public static readonly TimeSpan RequeueBackoff = TimeSpan.FromHours(24);
/// <summary>The event's <c>msiMatchedBy</c> value that makes its URL version-authoritative.</summary>
public const string MatchedByProductVersion = "productVersion";
private const string MsiFileName = "IntuneWindowsAgent.msi";
private const string AllowedHostSuffix = ".manage.microsoft.com";
/// <summary>Hard ceiling for one download attempt; the worker's heartbeat covers it.</summary>
/// <summary>Hard ceiling for one candidate download; the worker's heartbeat covers it.</summary>
private static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes(10);
/// <summary>Blob folder name = version — must be plain dotted digits (untrusted input).</summary>
@@ -116,6 +147,60 @@ namespace AutopilotMonitor.Functions.Services.Ime
return fileName.Equals(MsiFileName, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Candidate download URLs in trial order: the allowlisted event URL (if any) followed
/// by <see cref="FallbackMsiUrls"/>, de-duplicated case-insensitively.
/// </summary>
public static IReadOnlyList<string> BuildCandidateUrls(string? eventUrl)
{
var candidates = new List<string>(FallbackMsiUrls.Count + 1);
if (IsAllowedMsiUrl(eventUrl)) candidates.Add(eventUrl!);
foreach (var url in FallbackMsiUrls)
{
if (!candidates.Contains(url, StringComparer.OrdinalIgnoreCase)) candidates.Add(url);
}
return candidates;
}
/// <summary>
/// Version equality tolerant of component count: <c>1.105.103</c> equals
/// <c>1.105.103.0</c> (the CSP registry and the MSI both drop/keep trailing zeros
/// inconsistently). Unparseable input never matches.
/// </summary>
public static bool VersionsMatch(string? observed, string? productVersion)
{
if (string.IsNullOrWhiteSpace(observed) || string.IsNullOrWhiteSpace(productVersion)) return false;
if (!Version.TryParse(observed.Trim(), out var a) || !Version.TryParse(productVersion.Trim(), out var b)) return false;
return Normalize(a) == Normalize(b);
static Version Normalize(Version v) =>
new(v.Major, Math.Max(v.Minor, 0), Math.Max(v.Build, 0), Math.Max(v.Revision, 0));
}
/// <summary>
/// Decides whether a sighting of an ALREADY KNOWN version should re-queue the archive
/// job: only when the event carries a version-authoritative CSP URL
/// (<c>msiMatchedBy=productVersion</c>, allowlisted), the version is not archived (and
/// not permanently rejected as a bad version string), and the last archive activity is
/// older than <see cref="RequeueBackoff"/> — or there never was any (versions sighted
/// before the archiver existed, or archived by hand via the /ime-decompile skill; the
/// 409 path then heals the row to Archived).
/// </summary>
public static bool ShouldRequeueOnSighting(
ImeVersionSighting? sighting, string? msiDownloadUrl, string? msiMatchedBy, DateTime nowUtc)
{
if (sighting is null || sighting.IsNew) return false;
if (!IsAllowedMsiUrl(msiDownloadUrl)) return false;
if (!string.Equals(msiMatchedBy, MatchedByProductVersion, StringComparison.OrdinalIgnoreCase)) return false;
var status = sighting.MsiArchiveStatus;
if (string.Equals(status, Statuses.Archived, StringComparison.Ordinal)) return false;
if (string.Equals(status, Statuses.FailedBadVersion, StringComparison.Ordinal)) return false;
return sighting.MsiArchiveUpdatedAt is null
|| nowUtc - sighting.MsiArchiveUpdatedAt.Value >= RequeueBackoff;
}
public virtual async Task<ImeMsiArchiveResult> ArchiveAsync(
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
{
@@ -131,94 +216,128 @@ namespace AutopilotMonitor.Functions.Services.Ime
var version = envelope.Version!;
var blobPath = $"{version}/{MsiFileName}";
string sourceUrl;
if (IsAllowedMsiUrl(envelope.MsiDownloadUrl))
if (!string.IsNullOrEmpty(envelope.MsiDownloadUrl) && !IsAllowedMsiUrl(envelope.MsiDownloadUrl))
{
sourceUrl = envelope.MsiDownloadUrl!;
}
else
{
if (!string.IsNullOrEmpty(envelope.MsiDownloadUrl))
{
_logger.LogWarning(
"ImeMsiArchiver: event URL rejected by allowlist for version {Version} — using canonical URL",
version);
}
sourceUrl = CanonicalMsiUrl;
_logger.LogWarning(
"ImeMsiArchiver: event URL rejected by allowlist for version {Version} — using distribution hosts only",
version);
}
var candidates = BuildCandidateUrls(envelope.MsiDownloadUrl);
var attempts = new List<CandidateAttempt>(candidates.Count);
string? transientStatus = null;
try
{
var adminConfig = await GetAdminConfigurationAsync();
var maxSizeBytes = (long)adminConfig.MaxImeMsiDownloadSizeMB * 1024 * 1024;
using var cts = new CancellationTokenSource(DownloadTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
var (content, contentLength) = await OpenDownloadAsync(sourceUrl, linked.Token);
string sha256;
long totalBytes;
using (content)
foreach (var sourceUrl in candidates)
{
// Same fast-reject as the diagnostics proxy: cap of 0 means unlimited.
if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
{
_logger.LogWarning(
"ImeMsiArchiver: download for {Version} rejected — Content-Length {SizeBytes} exceeds limit {MaxSizeBytes}",
version, contentLength, maxSizeBytes);
return new ImeMsiArchiveResult(false, Statuses.FailedTooLarge, Retryable: false,
null, null, contentLength, sourceUrl);
}
cancellationToken.ThrowIfCancellationRequested();
using var cts = new CancellationTokenSource(DownloadTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
// Hash + count while streaming into the blob; the wrapper enforces the cap
// mid-stream too (a lying/absent Content-Length cannot bypass it).
using var guarded = new HashingCappedReadStream(content, maxSizeBytes);
try
{
await UploadMsiAsync(blobPath, guarded, linked.Token);
var (content, contentLength) = await OpenDownloadAsync(sourceUrl, linked.Token);
using (content)
{
// Same fast-reject as the diagnostics proxy: cap of 0 means unlimited.
if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
{
_logger.LogWarning(
"ImeMsiArchiver: candidate {SourceUrl} for {Version} rejected — Content-Length {SizeBytes} exceeds limit {MaxSizeBytes}",
sourceUrl, version, contentLength, maxSizeBytes);
attempts.Add(new CandidateAttempt(sourceUrl, "too-large", null));
continue;
}
// Spool to disk while hashing/capping (a lying/absent Content-Length
// cannot bypass the cap) — the version check needs random access.
using var spool = CreateSpool();
string sha256;
long totalBytes;
using (var guarded = new HashingCappedReadStream(content, maxSizeBytes))
{
await guarded.CopyToAsync(spool, linked.Token);
sha256 = guarded.Sha256Hex;
totalBytes = guarded.TotalBytes;
}
spool.Position = 0;
var productVersion = ReadProductVersion(spool);
if (!VersionsMatch(version, productVersion))
{
_logger.LogWarning(
"ImeMsiArchiver: candidate {SourceUrl} serves ProductVersion {ProductVersion} — not the observed {Version}; trying next host",
sourceUrl, productVersion ?? "(unreadable)", version);
attempts.Add(new CandidateAttempt(sourceUrl, "version-mismatch", productVersion));
continue;
}
attempts.Add(new CandidateAttempt(sourceUrl, "match", productVersion));
spool.Position = 0;
try
{
await UploadMsiAsync(blobPath, spool, linked.Token);
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Queue re-delivery after a successful upload: the archive already has
// this version — that IS the desired end state. But if the first
// attempt died between the MSI and the provenance upload, the sidecar
// is missing and this 409 pre-empts the happy path's provenance write
// on every future retry — so it must be healed here.
return await CompleteExistingArchiveAsync(
envelope, version, blobPath, sourceUrl, attempts, linked.Token);
}
await WriteProvenanceAsync(envelope, version, sourceUrl, sha256, totalBytes, productVersion, attempts, linked.Token);
_logger.LogInformation(
"ImeMsiArchiver: archived {Version} ({SizeBytes} bytes, sha256 {Sha256}, ProductVersion {ProductVersion}) from {SourceUrl} after {Attempts} candidate(s)",
version, totalBytes, sha256, productVersion, sourceUrl, attempts.Count);
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
blobPath, sha256, totalBytes, sourceUrl);
}
}
catch (RequestFailedException ex) when (ex.Status == 409)
catch (SizeCapExceededException)
{
// Queue re-delivery after a successful upload: the archive already has
// this version — that IS the desired end state. But if the first
// attempt died between the MSI and the provenance upload, the sidecar
// is missing and this 409 pre-empts the happy path's provenance write
// on every future retry — so it must be healed here.
return await CompleteExistingArchiveAsync(
envelope, version, blobPath, sourceUrl, linked.Token);
_logger.LogWarning(
"ImeMsiArchiver: candidate {SourceUrl} for {Version} exceeded the size cap mid-stream — aborted",
sourceUrl, version);
attempts.Add(new CandidateAttempt(sourceUrl, "too-large", null));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("ImeMsiArchiver: candidate {SourceUrl} for {Version} timed out", sourceUrl, version);
attempts.Add(new CandidateAttempt(sourceUrl, "timeout", null));
transientStatus ??= Statuses.FailedTimeout;
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "ImeMsiArchiver: candidate {SourceUrl} for {Version} failed to download", sourceUrl, version);
attempts.Add(new CandidateAttempt(sourceUrl, "download-failed", null));
transientStatus ??= Statuses.FailedDownload;
}
sha256 = guarded.Sha256Hex;
totalBytes = guarded.TotalBytes;
}
await WriteProvenanceAsync(envelope, version, sourceUrl, sha256, totalBytes, linked.Token);
_logger.LogInformation(
"ImeMsiArchiver: archived {Version} ({SizeBytes} bytes, sha256 {Sha256}) from {SourceUrl}",
version, totalBytes, sha256, sourceUrl);
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
blobPath, sha256, totalBytes, sourceUrl);
}
catch (SizeCapExceededException)
{
_logger.LogWarning(
"ImeMsiArchiver: download for {Version} exceeded the size cap mid-stream — aborted",
version);
return new ImeMsiArchiveResult(false, Statuses.FailedTooLarge, Retryable: false,
null, null, null, sourceUrl);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("ImeMsiArchiver: download for {Version} timed out", version);
return new ImeMsiArchiveResult(false, Statuses.FailedTimeout, Retryable: true,
null, null, null, sourceUrl);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "ImeMsiArchiver: download for {Version} failed", version);
return new ImeMsiArchiveResult(false, Statuses.FailedDownload, Retryable: true,
null, null, null, sourceUrl);
// No candidate produced an archive entry. Transient host problems are worth the
// queue's retry ladder; "every host served another build" is not — the ingest
// path re-queues that once a later sighting brings an authoritative URL.
var summary = string.Join(", ", attempts.Select(a => $"{a.Url} → {a.Outcome}{(a.ProductVersion is null ? string.Empty : $" ({a.ProductVersion})")}"));
if (transientStatus is not null)
{
_logger.LogWarning("ImeMsiArchiver: no candidate archived {Version} — transient failures: {Attempts}", version, summary);
return new ImeMsiArchiveResult(false, transientStatus, Retryable: true, null, null, null, null);
}
if (attempts.Count > 0 && attempts.All(a => a.Outcome == "too-large"))
{
_logger.LogWarning("ImeMsiArchiver: every candidate for {Version} exceeded the size cap: {Attempts}", version, summary);
return new ImeMsiArchiveResult(false, Statuses.FailedTooLarge, Retryable: false, null, null, null, null);
}
_logger.LogWarning("ImeMsiArchiver: no distribution host serves {Version} right now: {Attempts}", version, summary);
return new ImeMsiArchiveResult(false, Statuses.FailedVersionMismatch, Retryable: false, null, null, null, null);
}
catch (OperationCanceledException)
{
@@ -229,7 +348,7 @@ namespace AutopilotMonitor.Functions.Services.Ime
{
_logger.LogWarning(ex, "ImeMsiArchiver: archiving {Version} failed", version);
return new ImeMsiArchiveResult(false, Statuses.FailedError, Retryable: true,
null, null, null, sourceUrl);
null, null, null, null);
}
}
@@ -246,7 +365,7 @@ namespace AutopilotMonitor.Functions.Services.Ime
/// </summary>
private async Task<ImeMsiArchiveResult> CompleteExistingArchiveAsync(
ImeMsiArchiveEnvelope envelope, string version, string blobPath, string sourceUrl,
CancellationToken cancellationToken)
IReadOnlyList<CandidateAttempt> attempts, CancellationToken cancellationToken)
{
if (await ProvenanceExistsAsync($"{version}/provenance.json", cancellationToken))
{
@@ -259,35 +378,44 @@ namespace AutopilotMonitor.Functions.Services.Ime
string sha256;
long totalBytes;
string? archivedProductVersion;
using (var archived = await OpenArchivedMsiAsync(blobPath, cancellationToken))
using (var hashing = new HashingCappedReadStream(archived, maxBytes: 0))
using (var spool = CreateSpool())
{
await hashing.CopyToAsync(Stream.Null, cancellationToken);
sha256 = hashing.Sha256Hex;
totalBytes = hashing.TotalBytes;
using (var hashing = new HashingCappedReadStream(archived, maxBytes: 0))
{
await hashing.CopyToAsync(spool, cancellationToken);
sha256 = hashing.Sha256Hex;
totalBytes = hashing.TotalBytes;
}
spool.Position = 0;
archivedProductVersion = ReadProductVersion(spool);
}
await WriteProvenanceAsync(envelope, version, sourceUrl, sha256, totalBytes, cancellationToken);
await WriteProvenanceAsync(envelope, version, sourceUrl, sha256, totalBytes, archivedProductVersion, attempts, cancellationToken);
_logger.LogInformation(
"ImeMsiArchiver: healed missing provenance for already-archived {Version} ({SizeBytes} bytes, sha256 {Sha256})",
version, totalBytes, sha256);
"ImeMsiArchiver: healed missing provenance for already-archived {Version} ({SizeBytes} bytes, sha256 {Sha256}, ProductVersion {ProductVersion})",
version, totalBytes, sha256, archivedProductVersion ?? "(unreadable)");
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
blobPath, sha256, totalBytes, sourceUrl);
}
private async Task WriteProvenanceAsync(
ImeMsiArchiveEnvelope envelope, string version, string sourceUrl,
string sha256, long totalBytes, CancellationToken cancellationToken)
string sha256, long totalBytes, string? productVersion,
IReadOnlyList<CandidateAttempt> attempts, CancellationToken cancellationToken)
{
var provenance = new
{
version,
productVersion,
url = sourceUrl,
urlFromEvent = sourceUrl != CanonicalMsiUrl,
urlFromEvent = string.Equals(sourceUrl, envelope.MsiDownloadUrl, StringComparison.OrdinalIgnoreCase),
msiMatchedBy = envelope.MsiMatchedBy,
sha256,
msiBytes = totalBytes,
candidates = attempts.Select(a => new { a.Url, a.Outcome, a.ProductVersion }).ToList(),
firstSeenTenantId = envelope.TenantId,
firstSeenSessionId = envelope.SessionId,
archivedAtUtc = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
@@ -295,11 +423,17 @@ namespace AutopilotMonitor.Functions.Services.Ime
await UploadProvenanceAsync(
$"{version}/provenance.json",
System.Text.Json.JsonSerializer.Serialize(provenance,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
}),
cancellationToken);
}
// -------- Test seams (production defaults hit Azure / the network) --------
private sealed record CandidateAttempt(string Url, string Outcome, string? ProductVersion);
// -------- Test seams (production defaults hit Azure / the network / the disk) --------
/// <summary>Fetches the platform admin configuration (size cap).</summary>
protected virtual Task<AdminConfiguration> GetAdminConfigurationAsync()
@@ -316,6 +450,19 @@ namespace AutopilotMonitor.Functions.Services.Ime
return (content, response.Content.Headers.ContentLength ?? -1);
}
/// <summary>
/// Seekable scratch stream for one candidate download (the version check needs random
/// access; the blob upload benefits from a known length). A delete-on-close temp file
/// keeps a 13 MB installer — or a 250 MB cap-sized one — off the heap.
/// </summary>
protected virtual Stream CreateSpool()
=> new FileStream(Path.GetTempFileName(), FileMode.Create, FileAccess.ReadWrite, FileShare.None,
bufferSize: 81920, FileOptions.DeleteOnClose);
/// <summary>Reads the package's ProductVersion; null when the bytes are not a readable MSI.</summary>
protected virtual string? ReadProductVersion(Stream msi)
=> MsiProductVersionReader.TryReadProductVersion(msi);
/// <summary>
/// Uploads the installer stream write-once (If-None-Match:*). Implementations must
/// surface a conflict as <see cref="RequestFailedException"/> with status 409.
@@ -0,0 +1,383 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace AutopilotMonitor.Functions.Services.Ime
{
/// <summary>
/// Reads the <c>ProductVersion</c> property out of a Windows Installer package without
/// Windows Installer: an MSI is an OLE Compound File (CFB) whose tables are streams, so
/// the reader walks the CFB directory, decodes the MSI stream-name encoding, loads the
/// shared string pool (<c>_StringPool</c> + <c>_StringData</c>) and resolves the two
/// string-ref columns of the <c>Property</c> table. Pure managed code — it runs on the
/// Linux Function host where <c>msiexec</c>/<c>MsiGetProperty</c> do not exist.
/// <para>
/// Contract: never throws for malformed input — every structural problem yields
/// <c>null</c> ("could not read"), which the archiver treats as a version mismatch.
/// Bounded work: sector chains are capped at the FAT length, so a cyclic FAT cannot loop.
/// </para>
/// </summary>
internal static class MsiProductVersionReader
{
private const string ProductVersionProperty = "ProductVersion";
// Decoded names keep the '!' table-prefix marker (0x4840) — tables are "!<name>".
private const string StringPoolStream = "!_StringPool";
private const string StringDataStream = "!_StringData";
private const string PropertyTableStream = "!Property";
private const uint EndOfChain = 0xFFFFFFFE;
private const uint FreeSector = 0xFFFFFFFF;
private const int DirectoryEntrySize = 128;
private const int HeaderSize = 512;
private static readonly byte[] CfbSignature = { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 };
/// <summary>
/// Base-64 alphabet of the MSI stream-name encoding (Windows Installer stores table
/// names as UTF-16 code points in the 0x3800–0x4840 range, two characters per unit).
/// </summary>
private const string NameAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._";
/// <summary>
/// Returns the package's <c>ProductVersion</c> (e.g. <c>1.105.103.0</c>) or <c>null</c>
/// when the stream is not a readable MSI. <paramref name="msi"/> must be seekable; the
/// position is not restored.
/// </summary>
public static string? TryReadProductVersion(Stream msi)
{
if (msi is null || !msi.CanSeek || !msi.CanRead) return null;
try
{
var properties = ReadPropertyTable(msi);
return properties is not null && properties.TryGetValue(ProductVersionProperty, out var version)
&& !string.IsNullOrWhiteSpace(version)
? version.Trim()
: null;
}
catch (Exception ex) when (ex is IOException or ArgumentException or IndexOutOfRangeException
or OverflowException or InvalidDataException or DecoderFallbackException)
{
return null;
}
}
/// <summary>
/// The whole <c>Property</c> table (property → value). Internal for tests that cross-check
/// several properties against a real package; <c>null</c> when unreadable.
/// </summary>
internal static Dictionary<string, string>? ReadPropertyTable(Stream msi)
{
var cfb = CompoundFile.Open(msi);
if (cfb is null) return null;
var pool = cfb.ReadStream(StringPoolStream);
var data = cfb.ReadStream(StringDataStream);
var table = cfb.ReadStream(PropertyTableStream);
if (pool is null || data is null || table is null) return null;
var strings = DecodeStringPool(pool, data, out var refBytes);
if (strings is null) return null;
// Column-major: all "Property" refs first, then all "Value" refs.
var rowSize = 2 * refBytes;
if (table.Length < rowSize || table.Length % rowSize != 0) return null;
var rows = table.Length / rowSize;
var result = new Dictionary<string, string>(rows, StringComparer.Ordinal);
for (var row = 0; row < rows; row++)
{
var keyRef = ReadRef(table, row * refBytes, refBytes);
var valueRef = ReadRef(table, (rows + row) * refBytes, refBytes);
if (!strings.TryGetValue(keyRef, out var key) || !strings.TryGetValue(valueRef, out var value))
continue;
result[key] = value;
}
return result;
}
/// <summary>Decodes one MSI stream name (directory entry name → table name).</summary>
internal static string DecodeStreamName(string encoded)
{
var sb = new StringBuilder(encoded.Length * 2);
foreach (var ch in encoded)
{
if (ch >= 0x3800 && ch < 0x4800)
{
var v = ch - 0x3800;
sb.Append(NameAlphabet[v & 0x3F]).Append(NameAlphabet[(v >> 6) & 0x3F]);
}
else if (ch >= 0x4800 && ch < 0x4840)
{
sb.Append(NameAlphabet[ch - 0x4800]);
}
else if (ch == 0x4840)
{
sb.Append('!'); // table prefix marker
}
else
{
sb.Append(ch);
}
}
return sb.ToString();
}
/// <summary>
/// String pool layout: 4-byte entries (ushort length, ushort refcount). Entry 0 holds the
/// codepage; bit 15 of its refcount word selects 3-byte string refs in tables. A zero
/// length with a non-zero refcount marks a long string whose 32-bit length follows in
/// the next entry (which therefore yields no string id of its own). String ids are
/// 1-based pool positions.
/// </summary>
private static Dictionary<int, string>? DecodeStringPool(byte[] pool, byte[] data, out int refBytes)
{
refBytes = 2;
if (pool.Length < 4 || pool.Length % 4 != 0) return null;
var codepage = BinaryPrimitives.ReadUInt16LittleEndian(pool.AsSpan(0, 2));
var flags = BinaryPrimitives.ReadUInt16LittleEndian(pool.AsSpan(2, 2));
if ((flags & 0x8000) != 0) refBytes = 3;
var encoding = ResolveEncoding(codepage | ((flags & 0x7FFF) << 16));
var strings = new Dictionary<int, string>(pool.Length / 4);
var entries = pool.Length / 4;
var offset = 0;
for (var i = 1; i < entries; i++)
{
int len = BinaryPrimitives.ReadUInt16LittleEndian(pool.AsSpan(i * 4, 2));
int refs = BinaryPrimitives.ReadUInt16LittleEndian(pool.AsSpan(i * 4 + 2, 2));
var id = i;
if (len == 0)
{
if (refs == 0) continue; // hole (deleted string) — id stays unused
if (i + 1 >= entries) return null;
i++;
len = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(pool.AsSpan(i * 4, 4)));
}
if (len < 0 || offset + len > data.Length) return null;
strings[id] = encoding.GetString(data, offset, len);
offset += len;
}
return strings;
}
private static Encoding ResolveEncoding(int codepage)
{
if (codepage == 0 || codepage == 65001) return Encoding.UTF8;
if (codepage == 1252 || codepage == 1200) return Encoding.Latin1; // no CodePagesEncodingProvider needed for ASCII-range properties
try { return Encoding.GetEncoding(codepage); }
catch (Exception ex) when (ex is ArgumentException or NotSupportedException) { return Encoding.Latin1; }
}
private static int ReadRef(byte[] table, int offset, int refBytes)
=> refBytes == 2
? BinaryPrimitives.ReadUInt16LittleEndian(table.AsSpan(offset, 2))
: table[offset] | (table[offset + 1] << 8) | (table[offset + 2] << 16);
/// <summary>
/// Minimal read-only OLE Compound File (v3/v4) reader: header, DIFAT → FAT, directory,
/// mini-FAT/mini-stream. Only what the MSI property lookup needs.
/// </summary>
private sealed class CompoundFile
{
private readonly Stream _stream;
private readonly int _sectorSize;
private readonly int _miniSectorSize;
private readonly uint _miniStreamCutoff;
private readonly uint[] _fat;
private readonly uint[] _miniFat;
private readonly byte[] _miniStream;
private readonly List<(string Name, uint StartSector, uint Size)> _streams;
private CompoundFile(Stream stream, int sectorSize, int miniSectorSize, uint miniStreamCutoff,
uint[] fat, uint[] miniFat, byte[] miniStream, List<(string, uint, uint)> streams)
{
_stream = stream;
_sectorSize = sectorSize;
_miniSectorSize = miniSectorSize;
_miniStreamCutoff = miniStreamCutoff;
_fat = fat;
_miniFat = miniFat;
_miniStream = miniStream;
_streams = streams;
}
public static CompoundFile? Open(Stream stream)
{
if (stream.Length < HeaderSize) return null;
stream.Position = 0;
var header = new byte[HeaderSize];
if (!ReadExactly(stream, header)) return null;
if (!header.AsSpan(0, 8).SequenceEqual(CfbSignature)) return null;
var sectorShift = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(0x1E, 2));
var miniShift = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(0x20, 2));
if (sectorShift is < 7 or > 12 || miniShift is < 2 or > 12 || miniShift > sectorShift) return null;
var sectorSize = 1 << sectorShift;
var miniSectorSize = 1 << miniShift;
var fatSectorCount = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(0x2C, 4));
var firstDirSector = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(0x30, 4));
var miniCutoff = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(0x38, 4));
var firstMiniFatSector = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(0x3C, 4));
var firstDifatSector = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(0x44, 4));
var difatSectorCount = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(0x48, 4));
var totalSectors = Math.Max(0, (stream.Length + sectorSize - 1) / sectorSize - 1);
if (fatSectorCount > totalSectors || difatSectorCount > totalSectors) return null;
// DIFAT: 109 header entries, then a chain of DIFAT sectors.
var fatSectors = new List<uint>((int)fatSectorCount);
for (var i = 0; i < 109 && fatSectors.Count < fatSectorCount; i++)
{
var s = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(0x4C + i * 4, 4));
if (s >= EndOfChain) break;
fatSectors.Add(s);
}
var entriesPerSector = sectorSize / 4;
var difat = firstDifatSector;
for (var d = 0; d < difatSectorCount && difat < EndOfChain; d++)
{
var sector = ReadSector(stream, sectorSize, difat);
if (sector is null) return null;
for (var i = 0; i < entriesPerSector - 1 && fatSectors.Count < fatSectorCount; i++)
{
var s = BinaryPrimitives.ReadUInt32LittleEndian(sector.AsSpan(i * 4, 4));
if (s >= EndOfChain) break;
fatSectors.Add(s);
}
difat = BinaryPrimitives.ReadUInt32LittleEndian(sector.AsSpan((entriesPerSector - 1) * 4, 4));
}
var fat = new uint[fatSectors.Count * entriesPerSector];
for (var i = 0; i < fatSectors.Count; i++)
{
var sector = ReadSector(stream, sectorSize, fatSectors[i]);
if (sector is null) return null;
for (var j = 0; j < entriesPerSector; j++)
fat[i * entriesPerSector + j] = BinaryPrimitives.ReadUInt32LittleEndian(sector.AsSpan(j * 4, 4));
}
var directory = ReadChain(stream, sectorSize, fat, firstDirSector, uint.MaxValue);
if (directory is null || directory.Length < DirectoryEntrySize) return null;
// Root entry (index 0) owns the mini stream; MSI table streams live at root level,
// so a flat scan of stream entries is sufficient — no tree walk needed.
var streams = new List<(string, uint, uint)>();
uint rootStart = 0, rootSize = 0;
for (var off = 0; off + DirectoryEntrySize <= directory.Length; off += DirectoryEntrySize)
{
var type = directory[off + 66];
var nameLen = BinaryPrimitives.ReadUInt16LittleEndian(directory.AsSpan(off + 64, 2));
if (nameLen < 2 || nameLen > 64) continue;
var name = Encoding.Unicode.GetString(directory, off, nameLen - 2); // strip NUL
var start = BinaryPrimitives.ReadUInt32LittleEndian(directory.AsSpan(off + 116, 4));
var size = BinaryPrimitives.ReadUInt32LittleEndian(directory.AsSpan(off + 120, 4)); // low dword suffices (< 2 GB)
if (off == 0 && type == 5)
{
rootStart = start;
rootSize = size;
}
else if (type == 2)
{
streams.Add((DecodeStreamName(name), start, size));
}
}
uint[] miniFat = Array.Empty<uint>();
byte[] miniStream = Array.Empty<byte>();
if (firstMiniFatSector < EndOfChain && rootSize > 0)
{
var miniFatBytes = ReadChain(stream, sectorSize, fat, firstMiniFatSector, uint.MaxValue);
if (miniFatBytes is null) return null;
miniFat = new uint[miniFatBytes.Length / 4];
for (var i = 0; i < miniFat.Length; i++)
miniFat[i] = BinaryPrimitives.ReadUInt32LittleEndian(miniFatBytes.AsSpan(i * 4, 4));
miniStream = ReadChain(stream, sectorSize, fat, rootStart, rootSize) ?? Array.Empty<byte>();
}
return new CompoundFile(stream, sectorSize, miniSectorSize, miniCutoff, fat, miniFat, miniStream, streams);
}
/// <summary>Reads a root-level stream by decoded MSI name; <c>null</c> when absent or corrupt.</summary>
public byte[]? ReadStream(string decodedName)
{
foreach (var (name, start, size) in _streams)
{
if (!string.Equals(name, decodedName, StringComparison.Ordinal)) continue;
if (size == 0) return Array.Empty<byte>();
return size < _miniStreamCutoff
? ReadMiniChain(start, size)
: ReadChain(_stream, _sectorSize, _fat, start, size);
}
return null;
}
private byte[]? ReadMiniChain(uint start, uint size)
{
var result = new byte[size];
var written = 0;
var sector = start;
var hops = 0;
while (sector < EndOfChain && written < size)
{
if (++hops > _miniFat.Length) return null; // cyclic chain
var offset = (long)sector * _miniSectorSize;
var take = (int)Math.Min(_miniSectorSize, size - written);
if (offset + take > _miniStream.Length) return null;
Buffer.BlockCopy(_miniStream, (int)offset, result, written, take);
written += take;
if (sector >= _miniFat.Length) return null;
sector = _miniFat[sector];
}
return written == size ? result : null;
}
/// <summary>Follows a regular FAT chain; <paramref name="size"/> = uint.MaxValue reads the whole chain.</summary>
private static byte[]? ReadChain(Stream stream, int sectorSize, uint[] fat, uint start, uint size)
{
using var ms = new MemoryStream();
var sector = start;
var hops = 0;
while (sector < EndOfChain && ms.Length < size)
{
if (++hops > fat.Length) return null; // cyclic chain
var bytes = ReadSector(stream, sectorSize, sector);
if (bytes is null) return null;
var take = (int)Math.Min(sectorSize, size - ms.Length);
ms.Write(bytes, 0, take);
if (sector >= fat.Length) return null;
sector = fat[sector];
}
if (size != uint.MaxValue && ms.Length != size) return null;
return ms.ToArray();
}
private static byte[]? ReadSector(Stream stream, int sectorSize, uint sector)
{
if (sector >= FreeSector - 4) return null;
// Sector 0 starts one full sector into the file: the 512-byte header is padded
// to the sector size (matters for v4 packages with 4096-byte sectors).
var offset = ((long)sector + 1) * sectorSize;
if (offset + sectorSize > stream.Length) return null;
stream.Position = offset;
var buffer = new byte[sectorSize];
return ReadExactly(stream, buffer) ? buffer : null;
}
private static bool ReadExactly(Stream stream, byte[] buffer)
{
var total = 0;
while (total < buffer.Length)
{
var read = stream.Read(buffer, total, buffer.Length - total);
if (read <= 0) return false;
total += read;
}
return true;
}
}
}
}

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