mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1daf631a02 | |||
| d0190f443d | |||
| e96f49799f | |||
| 4864b2d21d | |||
| 56c37866d1 | |||
| 2852418413 | |||
| 268d8652f1 |
@@ -76,12 +76,23 @@ jobs:
|
||||
- name: Reserve build number (counter blob CAS)
|
||||
id: version
|
||||
shell: pwsh
|
||||
# Input and secret reach the script only as environment variables, never as script
|
||||
# text: ${{ }} is substituted before pwsh parses the block, so a crafted
|
||||
# build_number could otherwise break out of the quotes and run arbitrary code.
|
||||
env:
|
||||
BUILD_NUMBER_OVERRIDE: ${{ inputs.build_number }}
|
||||
AGENT_SAS_TOKEN: ${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}
|
||||
run: |
|
||||
$override = 0
|
||||
if ('${{ inputs.build_number }}' -ne '') { $override = [int]'${{ inputs.build_number }}' }
|
||||
if ($env:BUILD_NUMBER_OVERRIDE -ne '') {
|
||||
if ($env:BUILD_NUMBER_OVERRIDE -notmatch '^\d+$') {
|
||||
throw "build_number must be a positive integer, got '$($env:BUILD_NUMBER_OVERRIDE)'"
|
||||
}
|
||||
$override = [int]$env:BUILD_NUMBER_OVERRIDE
|
||||
}
|
||||
|
||||
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' -Override $override
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken $env:AGENT_SAS_TOKEN -Override $override
|
||||
|
||||
$csproj = Get-Content $env:CSPROJ_PATH -Raw
|
||||
if ($csproj -match '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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.
|
||||
description: Why a GlobalAdmins, DelegatedAdmins or McpUsers 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 the role services and the MCP whitelist, the first-sign-in object-id pin, and the explicit rebind surface.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs
|
||||
tags:
|
||||
- backend
|
||||
@@ -10,7 +10,8 @@ tags:
|
||||
- multi-tenant
|
||||
- global-admin
|
||||
- delegated-admin
|
||||
timestamp: 2026-08-29T00:00:00+02:00
|
||||
- mcp
|
||||
timestamp: 2026-08-30T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -83,10 +84,28 @@ winner). Two accounts racing the first sign-in inside the home tenant therefore
|
||||
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.
|
||||
|
||||
## The MCP whitelist is a third UPN-keyed grant
|
||||
|
||||
`McpUsers` (PK `McpUsers`, RK = lowercase UPN) is the WhitelistOnly allow-list consulted by
|
||||
`GET auth/mcp`, which runs under the AuthenticatedUser policy — a token from any Entra tenant reaches it.
|
||||
Before 2026-08-30 the row was honoured by UPN alone, so a same-UPN identity from another tenant
|
||||
received `allowed=true` / `accessGrant=McpUser`, inherited the row's `UsagePlan` (a paid quota tier
|
||||
provisioned for another organization's user), and was also hit by the row's Disabled kill-switch. The
|
||||
data reach stayed tenant-bound (the MCP server routes a non-global caller on its own JWT `tid`), but the
|
||||
GA-curated whitelist and the per-user entitlement were bypassable.
|
||||
|
||||
`McpUserService` now resolves the whitelist exactly like the role services: row by UPN (cached 30 s) →
|
||||
no row ⇒ `NotPresent` without touching the binding → `IsBoundAsync(identity)` false ⇒ `NotPresent`. Grant,
|
||||
usage-plan override (`GetBoundMcpUserAsync`, used by `McpQuotaService.ResolvePlanAsync` and the
|
||||
self-service usage view) and the Disabled kill-switch all belong to the bound identity only; an unbound
|
||||
legacy row is inert. `POST global/mcp-users` binds first via the same resolver / 422 / 409 contract as the
|
||||
other grant endpoints, and the MCP users settings section shows the same home-tenant picker on 422. The
|
||||
AllMembers path is unaffected — it is tenant-bound by the auth middleware itself.
|
||||
|
||||
## 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` /
|
||||
Granting is "type the UPN". The four grant endpoints (`POST auth/global-admins`,
|
||||
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`, `POST global/mcp-users`) 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
|
||||
@@ -98,7 +117,7 @@ Granting is "type the UPN". The three grant endpoints (`POST auth/global-admins`
|
||||
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
|
||||
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync`, `AssignGroupAsync` and `AddMcpUserAsync` 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
|
||||
@@ -158,7 +177,7 @@ Same row, binding without an object id: the first `tid=1111…` sign-in pins its
|
||||
* `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/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs`, `McpUserService.cs` — resolution order (`McpUserServiceIdentityBindingTests` for the whitelist matrix).
|
||||
* `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.
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
- archive
|
||||
- blob
|
||||
- queue
|
||||
timestamp: 2026-08-29T17:30:00+02:00
|
||||
timestamp: 2026-08-30T12:00:00+02:00
|
||||
---
|
||||
|
||||
# IME Installer Archive
|
||||
@@ -22,7 +22,7 @@ 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) |
|
||||
| Table `ImeVersionHistory` (partition `Global`, RowKey = version) | first/last seen, session count, `CorroboratedAt`, `MsiArchiveStatus`, `MsiArchiveUpdatedAt`, `MsiArchiveBlobPath`, `MsiSha256`, `MsiBytes`, `MsiSourceUrl` | `RecordImeVersionAsync` (sighting), `UpdateImeVersionArchiveInfoAsync` (archive outcome / queued stamp) |
|
||||
|
||||
# Schema
|
||||
|
||||
@@ -45,6 +45,25 @@ older than 24 h (`RequeueBackoff`) or never happened. The ingest path then merge
|
||||
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.
|
||||
|
||||
## Trust boundary of a sighting
|
||||
|
||||
The version string is **device-supplied** (any device holding a tenant agent credential can
|
||||
send an `ime_agent_version` event) and becomes the RowKey of a caller-independent GLOBAL
|
||||
partition that every tenant reads. Four layers keep one tenant's device from turning a
|
||||
claim into a platform fact:
|
||||
|
||||
| Layer | Where | Effect |
|
||||
| --- | --- | --- |
|
||||
| Plausibility guard | `ImeMsiArchiver.IsPlausibleVersion`, called first in `RecordImeVersionAsync` (and again by the archiver) | plain dotted digits, 2–4 components, inside Windows Installer `ProductVersion` bounds (major/minor ≤ 255, build ≤ 65535). Anything else → `ImeVersionSighting.Rejected`: no row, no ops event, no archive job, no re-queue. |
|
||||
| One sighting per session | `UpdateSessionImeAgentVersionAsync` returns whether the session's stored version changed; only then does the ingest path call `RecordImeVersionAsync` | `SessionCount` moves once per session and version; a re-sent or replayed event, a tombstoned or unregistered session, count nothing. |
|
||||
| Corroboration | merge path of `RecordImeVersionAsync` stamps `CorroboratedAt` on the first sighting from a tenant other than `FirstSeenTenantId` | the first-seen tenant's own devices can never corroborate their own claim. |
|
||||
| Fleet confirmation on read | `GetImeVersionHistoryFunction.IsFleetConfirmed` (MemberRead projection only) | tenant members see a version once it is `Archived` (ProductVersion matched a real package), or corroborated, or first seen before `LegacyTrustCutoffUtc` (2026-08-31 — the 16 rows reviewed on 2026-08-30 are all genuine builds). Global scope sees every row, unconfirmed ones included. |
|
||||
|
||||
What remains possible for a hostile device: a plausible, never-shipped version creates a
|
||||
row visible to Global Admins plus one `NewImeVersionDetected` ops event and one archive walk
|
||||
that ends `Failed:VersionMismatch` (no blob). It is never listed to other tenants and can
|
||||
never occupy the archive folder of a real build.
|
||||
|
||||
## Candidate hosts and the version check
|
||||
|
||||
Microsoft distributes IME from several versionless URLs that carry **different rollout
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [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, the shadow-then-enforce rollout with its volume-controlled telemetry, and why `X-ARR-ClientCert` is the only certificate transport (TLS handshake = proof of possession; bootstrap routes token-only, fail-closed).
|
||||
* [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.
|
||||
* [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, the skill-side overwrite path for corrections, and the trust boundary of a device-reported version (plausibility guard, one sighting per session, second-tenant corroboration, fleet-confirmed tenant view).
|
||||
|
||||
# Conventions for this bundle
|
||||
|
||||
|
||||
+12
@@ -1,5 +1,17 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-30 (6)
|
||||
|
||||
* **Update**: `rules/rule-id-namespace.md` — guard 1 rewritten: gather RuleStats (tenant + `global_*` rows) are now resolved against the tenant's active gather catalog (`EventIngestProcessor.ResolveFiredGatherRules`), metadata catalog-sourced, unknown IDs dropped — same design as the analyze path; the `IsReservedBuiltInId` pattern gate on device-supplied IDs is gone.
|
||||
|
||||
## 2026-08-30 (5)
|
||||
|
||||
* **Update**: `backend/admin-identity-binding.md` — new "The MCP whitelist is a third UPN-keyed grant" section: `McpUsers` rows (grant, `UsagePlan` override and Disabled kill-switch) are honoured only for the identity the UPN is bound to (`McpUserService` row → `IsBoundAsync`, `GetBoundMcpUserAsync` for the quota plan); `POST global/mcp-users` binds first with the shared resolver / 422 / 409 contract; unbound legacy rows are inert.
|
||||
|
||||
## 2026-08-30 (4)
|
||||
|
||||
* **Update**: `backend/ime-msi-archive.md` — new "Trust boundary of a sighting" section: the device-supplied `ime_agent_version` string is gated by `ImeMsiArchiver.IsPlausibleVersion` (dotted digits within MSI ProductVersion bounds) before it can become a GLOBAL `ImeVersionHistory` RowKey (`ImeVersionSighting.Rejected` → no row/ops event/archive job); a session counts once (`UpdateSessionImeAgentVersionAsync` returns whether the version changed); a second tenant's sighting stamps `CorroboratedAt`; the MemberRead projection lists only fleet-confirmed versions (Archived, corroborated, or first seen before 2026-08-31), Global scope sees every row.
|
||||
|
||||
## 2026-08-30 (3)
|
||||
|
||||
* **Update**: `agent/local-admin-analyzer.md` — built-ins matched by well-known RID (500/501/503/504) so localized Guest/Administrator are not flagged now that disabled accounts are inventoried; `kioskUser0` built-in; shutdown-time dynamic allowance restricted to non-SAM identities (a logged-in local account is never exempted — CWE-807) with new `logged_in_local_accounts` field; junctions and `$<32 hex>` folders skipped by the profile check.
|
||||
|
||||
@@ -31,7 +31,7 @@ Namespace split (`RuleIdPolicy.cs`, shared model):
|
||||
|
||||
# Guards (all enforced, 2026-08-21)
|
||||
|
||||
1. **Global RuleStats aggregate is catalog-only.** The `global_{ruleId}` row would otherwise sum same-ID custom rules of unrelated tenants (title/severity last-writer-wins). Analyze gates on `rule.IsBuiltIn || rule.IsCommunity` (`AnalyzeOnEnrollmentEndHandler`), gather gates on `RuleIdPolicy.IsReservedBuiltInId` (`EventIngestProcessor.RuleStats`) because agent events carry no flag — valid because custom rules can never occupy the reserved pattern.
|
||||
1. **Global RuleStats aggregate is catalog-only.** The `global_{ruleId}` row would otherwise sum same-ID custom rules of unrelated tenants (title/severity last-writer-wins). Both paths gate on the server-side catalog rule object: analyze on `rule.IsBuiltIn || rule.IsCommunity` of the evaluated rule (`AnalyzeOnEnrollmentEndHandler`), gather on the same flags of the rule resolved from the tenant's active gather catalog (`EventIngestProcessor.ResolveFiredGatherRules`). The agent event contributes only the `ruleId`; title/category/severity are taken from the catalog, never from event `Data`, and a claimed ID that is not an active rule for the tenant is dropped — so a device cannot mint `global_*` rows for retired/unshipped IDs, inflate a built-in rule's counters under a foreign ID, or respell its title. (Earlier the gather path selected the global row by `RuleIdPolicy.IsReservedBuiltInId` on the device-supplied ID and wrote the device-supplied title — pattern membership is not catalog membership.)
|
||||
2. **Catalog build fails on namespace violations.** `rules/scripts/combine.js` rejects duplicate IDs (case-insensitive) and any gather/analyze built-in outside the reserved pattern; `BuiltInRuleCatalogPolicyTests` pins the same invariants on the embedded resource actually deployed.
|
||||
3. **GitHub reseed rejects non-reserved IDs** before writing to the `global` partition (`ReseedFromGitHubFunction`) — a seeded `*-CUSTOM-*` rule would silently shadow every tenant's same-ID custom rule at merge time.
|
||||
4. **Cross-tenant RuleState GC is namespace-gated.** `DeleteRuleStatesForRuleIdAcrossTenantsAsync` deletes by bare RowKey across all partitions; it now refuses non-reserved IDs with `failed=-1` (callers then skip the global catalog delete, same contract as enumeration failure).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
@@ -268,8 +268,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
// Step 3: Download ZIP (timeout = downloadTimeoutMs)
|
||||
// ZIP lives under %ProgramData% (SYSTEM/Admin ACL) instead of C:\Windows\Temp so
|
||||
// a non-admin local user cannot plant a junction at the ZIP filename.
|
||||
// ZIP lives under %ProgramData% instead of C:\Windows\Temp so a non-admin local
|
||||
// user cannot plant a junction at the ZIP filename. The directory inherits the
|
||||
// ProgramData ACL (no explicit hardening — the updater runs during OOBE where only
|
||||
// SYSTEM exists); the verify→extract TOCTOU is closed by the exclusive handle below.
|
||||
var zipDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateDownloadDir);
|
||||
Directory.CreateDirectory(zipDir);
|
||||
var zipPath = Path.Combine(zipDir, "AutopilotMonitor-Agent-Update.zip");
|
||||
@@ -304,10 +306,51 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
expectedSha256 = null;
|
||||
}
|
||||
|
||||
var verifySw = Stopwatch.StartNew();
|
||||
var integrityOk = VerifyZipIntegrity(zipPath, expectedSha256, log);
|
||||
verifySw.Stop();
|
||||
verifyMs = verifySw.ElapsedMilliseconds;
|
||||
// Steps 3b + 4 share ONE exclusive handle (FileShare.None): the bytes that are
|
||||
// hashed are the bytes that get extracted. With separate opens a local user who
|
||||
// pre-owned the Updates directory could swap the ZIP after the hash passed and
|
||||
// before extraction (TOCTOU). The handle is disposed BEFORE CleanupStaging so the
|
||||
// ZIP delete does not fail with a sharing violation.
|
||||
FileStream zipStream;
|
||||
try
|
||||
{
|
||||
zipStream = new FileStream(zipPath, FileMode.Open, FileAccess.Read, FileShare.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: cannot open downloaded ZIP exclusively — {ex.Message}. Aborting update.");
|
||||
CleanupStaging(null, zipPath);
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "downloaded ZIP could not be opened exclusively", log);
|
||||
return;
|
||||
}
|
||||
|
||||
bool integrityOk;
|
||||
bool extractOk;
|
||||
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
|
||||
using (zipStream)
|
||||
{
|
||||
var verifySw = Stopwatch.StartNew();
|
||||
integrityOk = VerifyZipIntegrity(zipStream, expectedSha256, log);
|
||||
verifySw.Stop();
|
||||
verifyMs = verifySw.ElapsedMilliseconds;
|
||||
|
||||
if (integrityOk)
|
||||
{
|
||||
// PR5: integrity verified — silent before, opaque whether the verify step actually ran.
|
||||
log($"Self-update: ZIP integrity verified ({verifyMs}ms)");
|
||||
|
||||
// Step 4: Extract to staging directory (same handle as the hash above)
|
||||
var extractSw = Stopwatch.StartNew();
|
||||
extractOk = ExtractToStaging(zipStream, stagingDir, log);
|
||||
extractSw.Stop();
|
||||
extractMs = extractSw.ElapsedMilliseconds;
|
||||
}
|
||||
else
|
||||
{
|
||||
extractOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!integrityOk)
|
||||
{
|
||||
@@ -316,15 +359,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log);
|
||||
return;
|
||||
}
|
||||
// PR5: integrity verified — silent before, opaque whether the verify step actually ran.
|
||||
log($"Self-update: ZIP integrity verified ({verifyMs}ms)");
|
||||
|
||||
// Step 4: Extract to staging directory
|
||||
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
|
||||
var extractSw = Stopwatch.StartNew();
|
||||
var extractOk = ExtractToStaging(zipPath, stagingDir, log);
|
||||
extractSw.Stop();
|
||||
extractMs = extractSw.ElapsedMilliseconds;
|
||||
if (!extractOk)
|
||||
{
|
||||
if (writeMarkers)
|
||||
@@ -444,8 +478,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// <summary>
|
||||
/// Verifies the SHA-256 hash of the downloaded ZIP against the expected hash.
|
||||
/// Returns true if the hash matches or if no expected hash is available (backward compat).
|
||||
/// Operates on the caller's exclusive handle (not the path) so the verified bytes are the
|
||||
/// ones later extracted; the stream is rewound to position 0 on return.
|
||||
/// </summary>
|
||||
private static bool VerifyZipIntegrity(string zipPath, string expectedSha256, Action<string> log)
|
||||
private static bool VerifyZipIntegrity(FileStream zipStream, string expectedSha256, Action<string> log)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expectedSha256))
|
||||
{
|
||||
@@ -456,9 +492,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
try
|
||||
{
|
||||
using (var sha256 = SHA256.Create())
|
||||
using (var stream = File.OpenRead(zipPath))
|
||||
{
|
||||
var hashBytes = sha256.ComputeHash(stream);
|
||||
zipStream.Position = 0;
|
||||
var hashBytes = sha256.ComputeHash(zipStream);
|
||||
zipStream.Position = 0;
|
||||
var actualHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||
|
||||
if (string.Equals(actualHash, expectedSha256.ToLowerInvariant(), StringComparison.Ordinal))
|
||||
@@ -576,7 +613,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// the staging dir (e.g. ..\..\System32\evil.dll). We iterate entries manually and
|
||||
/// reject any whose resolved absolute path falls outside the staging root.
|
||||
/// </summary>
|
||||
private static bool ExtractToStaging(string zipPath, string stagingDir, Action<string> log)
|
||||
private static bool ExtractToStaging(FileStream zipStream, string stagingDir, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -592,7 +629,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
stagingRoot += Path.DirectorySeparatorChar;
|
||||
|
||||
int fileCount = 0;
|
||||
using (var archive = ZipFile.OpenRead(zipPath))
|
||||
zipStream.Position = 0;
|
||||
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true))
|
||||
{
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
|
||||
@@ -81,7 +81,7 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
var mcpUserMock = new Mock<McpUserService>(
|
||||
adminRepo, cache, Mock.Of<ILogger<McpUserService>>(),
|
||||
adminRepo, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of<ILogger<McpUserService>>(),
|
||||
globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// RuleStats for gather rules are resolved against the tenant's active catalog, never from
|
||||
/// device-supplied event fields: a forged ruleId/ruleTitle cannot mint or respell a
|
||||
/// (global) RuleStats row. Mirrors the analyze path's catalog-only design.
|
||||
/// </summary>
|
||||
public class EventIngestProcessorGatherRuleStatsTests
|
||||
{
|
||||
private static GatherRule Rule(string id, string title, bool builtIn = true) => new GatherRule
|
||||
{
|
||||
RuleId = id, Title = title, Category = "Network", OutputSeverity = "Warning",
|
||||
IsBuiltIn = builtIn, IsCommunity = false, Enabled = true
|
||||
};
|
||||
|
||||
private static EnrollmentEvent GatherEvent(string? ruleId, string? ruleTitle = null, string source = "GatherRuleExecutor")
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
if (ruleId != null) data["ruleId"] = ruleId;
|
||||
if (ruleTitle != null) data["ruleTitle"] = ruleTitle;
|
||||
return new EnrollmentEvent { Source = source, Data = data };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metadata_comes_from_catalog_not_from_event()
|
||||
{
|
||||
var catalog = new[] { Rule("GATHER-NET-001", "Catalog title") };
|
||||
var events = new[] { GatherEvent("GATHER-NET-001", "<attacker text>") };
|
||||
|
||||
var fired = EventIngestProcessor.ResolveFiredGatherRules(events, catalog);
|
||||
|
||||
var rule = Assert.Single(fired);
|
||||
Assert.Equal("Catalog title", rule.Title);
|
||||
Assert.Equal("Network", rule.Category);
|
||||
Assert.Equal("Warning", rule.OutputSeverity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GATHER-NET-999")] // reserved namespace, not in catalog (retired / unshipped)
|
||||
[InlineData("ANALYZE-ESP-001")] // reserved namespace of the other rule type
|
||||
[InlineData("CUSTOM-X")] // unknown custom id
|
||||
public void Claimed_id_outside_active_catalog_is_dropped(string forgedId)
|
||||
{
|
||||
var catalog = new[] { Rule("GATHER-NET-001", "Catalog title") };
|
||||
var events = new[] { GatherEvent(forgedId, "forged") };
|
||||
|
||||
Assert.Empty(EventIngestProcessor.ResolveFiredGatherRules(events, catalog));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_GatherRuleExecutor_events_with_ruleId_count_once_per_batch()
|
||||
{
|
||||
var catalog = new[] { Rule("GATHER-NET-001", "A"), Rule("GATHER-NET-002", "B") };
|
||||
var events = new[]
|
||||
{
|
||||
GatherEvent("GATHER-NET-001"),
|
||||
GatherEvent("gather-net-001"), // duplicate (case-insensitive)
|
||||
GatherEvent("GATHER-NET-002", source: "OtherSource"), // wrong source
|
||||
GatherEvent(null), // no ruleId
|
||||
new EnrollmentEvent { Source = "GatherRuleExecutor", Data = null! },
|
||||
};
|
||||
|
||||
var fired = EventIngestProcessor.ResolveFiredGatherRules(events, catalog);
|
||||
|
||||
var rule = Assert.Single(fired);
|
||||
Assert.Equal("GATHER-NET-001", rule.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_tenant_rule_resolves_but_is_not_global()
|
||||
{
|
||||
var catalog = new[] { Rule("MY-RULE", "Custom", builtIn: false) };
|
||||
var fired = EventIngestProcessor.ResolveFiredGatherRules(new[] { GatherEvent("MY-RULE") }, catalog);
|
||||
|
||||
var rule = Assert.Single(fired);
|
||||
Assert.False(rule.IsBuiltIn || rule.IsCommunity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,13 +123,107 @@ public class GetImeVersionHistoryRedactionTests
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt",
|
||||
"corroboratedAt", "firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt",
|
||||
"msiArchiveBlobPath", "msiArchiveStatus", "msiArchiveUpdatedAt", "msiBytes", "msiSha256", "msiSourceUrl",
|
||||
"sessionCount", "version",
|
||||
},
|
||||
keys);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Fleet confirmation — what a tenant member may see at all
|
||||
// =========================================================================
|
||||
//
|
||||
// A row exists for whatever string a device reports (guarded for plausibility only).
|
||||
// One tenant's device must not be able to publish an unverified "version" into every
|
||||
// other tenant's rollout view, so the MemberRead projection lists a version only once
|
||||
// the installer was archived with a matching ProductVersion, a second tenant reported
|
||||
// it, or the row predates the guard. Global scope sees every row — including the ones
|
||||
// that need investigating.
|
||||
|
||||
private static readonly DateTime AfterCutoff = GetImeVersionHistoryFunction.LegacyTrustCutoffUtc.AddDays(1);
|
||||
|
||||
private static ImeVersionHistoryEntry Unconfirmed() =>
|
||||
new() { Version = "1.86.999.0", FirstSeenAt = AfterCutoff, LastSeenAt = AfterCutoff, SessionCount = 1 };
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_DoesNotSeeAnUnconfirmedVersion()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, Unconfirmed()));
|
||||
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalCaller_SeesAnUnconfirmedVersion()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, Unconfirmed()));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal("1.86.999.0", doc.RootElement[0].GetProperty("version").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_SeesAVersion_OnceItsInstallerIsArchived()
|
||||
{
|
||||
var entry = Unconfirmed();
|
||||
entry.MsiArchiveStatus = "Archived";
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, entry));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Queued")]
|
||||
[InlineData("Failed:VersionMismatch")]
|
||||
[InlineData("Failed:Download")]
|
||||
public void NonGlobalCaller_DoesNotSeeAVersion_WhoseArchiveIsNotConfirmed(string status)
|
||||
{
|
||||
var entry = Unconfirmed();
|
||||
entry.MsiArchiveStatus = status;
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, entry));
|
||||
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_SeesAVersion_OnceASecondTenantReportedIt()
|
||||
{
|
||||
var entry = Unconfirmed();
|
||||
entry.CorroboratedAt = AfterCutoff.AddHours(2);
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, entry));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_SeesLegacyRows_ThatPredateTheGuard()
|
||||
{
|
||||
var entry = Unconfirmed();
|
||||
entry.FirstSeenAt = GetImeVersionHistoryFunction.LegacyTrustCutoffUtc.AddSeconds(-1);
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, entry));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_FilterKeepsOrderOfTheRemainingRows()
|
||||
{
|
||||
var newest = MakeEntry("1.103.101.0", First);
|
||||
var bogus = Unconfirmed();
|
||||
var older = MakeEntry("1.101.111.0", First.AddDays(-48));
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(false, newest, bogus, older));
|
||||
|
||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal("1.103.101.0", doc.RootElement[0].GetProperty("version").GetString());
|
||||
Assert.Equal("1.101.111.0", doc.RootElement[1].GetProperty("version").GetString());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shape-preserving properties of the projection
|
||||
// =========================================================================
|
||||
|
||||
@@ -155,6 +155,41 @@ public class ImeMsiArchiverTests
|
||||
Assert.Equal(0, archiver.DownloadCalls + archiver.UploadCalls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same guard gates the GLOBAL ImeVersionHistory row (RecordImeVersionAsync) — a
|
||||
/// device-reported string that cannot be a Windows Installer ProductVersion must create
|
||||
/// nothing anywhere. MSI bounds: major/minor ≤ 255, build ≤ 65535.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("1.105.103.0", true)]
|
||||
[InlineData("1.105.103", true)]
|
||||
[InlineData("1.0", true)]
|
||||
[InlineData("1.86.999.0", true)] // plausible future build — only the archiver's ProductVersion check can refute it
|
||||
[InlineData("1", false)] // single component
|
||||
[InlineData("1.86.999999.0", false)] // build > 65535
|
||||
[InlineData("256.1.1.0", false)] // major > 255
|
||||
[InlineData("1.256.1.0", false)] // minor > 255
|
||||
[InlineData("1.1.1.99999", false)] // revision > 65535
|
||||
[InlineData("1.104.102.0.1", false)]
|
||||
[InlineData("1.104../escape", false)]
|
||||
[InlineData("<script>", false)]
|
||||
[InlineData("1.105.103.0 ", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsPlausibleVersion_cases(string? version, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeMsiArchiver.IsPlausibleVersion(version));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRequeueOnSighting_RejectedSighting_False()
|
||||
{
|
||||
// A rejected string has no row; an "unarchived" (null) status must not read as "re-queue".
|
||||
var rejected = new ImeVersionSighting { Rejected = true };
|
||||
|
||||
Assert.False(ImeMsiArchiver.ShouldRequeueOnSighting(rejected, HotfixUrl, "productVersion", DateTime.UtcNow));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// URL selection + version verification
|
||||
// =========================================================================
|
||||
|
||||
@@ -70,7 +70,8 @@ public class McpQuotaServiceTests
|
||||
string? planDefinitionsJson = null,
|
||||
string? mcpUserPlanOverride = null,
|
||||
TenantEdition edition = TenantEdition.Community,
|
||||
IMemoryCache? cache = null)
|
||||
IMemoryCache? cache = null,
|
||||
bool bound = true)
|
||||
{
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny<string>()))
|
||||
@@ -80,7 +81,7 @@ public class McpQuotaServiceTests
|
||||
|
||||
cache ??= new MemoryCache(new MemoryCacheOptions());
|
||||
var mcpUserService = new McpUserService(
|
||||
adminRepo.Object, cache, NullLogger<McpUserService>.Instance,
|
||||
adminRepo.Object, new StubAdminIdentityBindingService(bound), cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!);
|
||||
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
@@ -183,6 +184,21 @@ public class McpQuotaServiceTests
|
||||
Assert.Equal(10000, d.DailyLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_PerUserOverride_IgnoredWhenCallerIsNotTheBoundIdentity()
|
||||
{
|
||||
// A McpUsers row is keyed on the UPN string; its plan override belongs to the identity (tid + oid)
|
||||
// the UPN is bound to. A same-UPN caller from another tenant gets its own tenant's edition plan.
|
||||
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power", bound: false);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("community", d.Plan);
|
||||
Assert.Equal(100, d.DailyLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_UnknownOverridePlan_FailsClosedToCommunityLimits()
|
||||
{
|
||||
|
||||
@@ -52,7 +52,7 @@ public class McpUserServiceDelegatedTests
|
||||
SetPolicy(McpAccessPolicy.WhitelistOnly);
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, cache, NullLogger<McpUserService>.Instance,
|
||||
_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, _adminConfig.Object);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
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>
|
||||
/// The McpUsers whitelist is keyed on a mutable, globally non-unique UPN string while the API accepts
|
||||
/// tokens from every Entra tenant. A whitelist row (its grant, its usage-plan override AND its Disabled
|
||||
/// kill-switch) must therefore be honoured only for the identity the UPN is bound to (tid + oid via
|
||||
/// AdminIdentityBindings) — a foreign-tenant or recycled-UPN token carrying the same UPN sees no row.
|
||||
/// </summary>
|
||||
public class McpUserServiceIdentityBindingTests
|
||||
{
|
||||
private const string Upn = "user@customer.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 readonly AdminIdentity Bound = AdminIdentity.Create(Upn, HomeTenant, Oid)!;
|
||||
|
||||
private readonly Mock<IAdminRepository> _adminRepo = new();
|
||||
private readonly StubAdminIdentityBindingService _bindings;
|
||||
private readonly McpUserService _sut;
|
||||
|
||||
public McpUserServiceIdentityBindingTests()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
// Binding verdict = "is this exactly the bound identity?" — the real service's semantics for a row
|
||||
// bound to (HomeTenant, Oid).
|
||||
_bindings = new StubAdminIdentityBindingService(id =>
|
||||
id != null
|
||||
&& string.Equals(id.Upn, Upn, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(id.TenantId, HomeTenant, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(id.ObjectId, Oid, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var globalAdmin = new Mock<GlobalAdminService>(
|
||||
_adminRepo.Object, _bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
|
||||
var delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object, _bindings,
|
||||
new StubTenantEntitlementService(TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.WhitelistOnly.ToString() });
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, _bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object);
|
||||
}
|
||||
|
||||
private void SetRow(bool enabled, string? plan = "pro") =>
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(Upn))
|
||||
.ReturnsAsync(new McpUserEntry { Upn = Upn, IsEnabled = enabled, UsagePlan = plan });
|
||||
|
||||
// ── grant ────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BoundIdentity_WithEnabledRow_IsGranted()
|
||||
{
|
||||
SetRow(enabled: true);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal("McpUser", result.AccessGrant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeignTenantToken_WithSameUpn_IsDenied()
|
||||
{
|
||||
// The nOAuth-class premise: a token from an attacker-controlled tenant whose UPN claim renders as the
|
||||
// whitelisted string. Genuine token, only tid differs — must not inherit the row.
|
||||
SetRow(enabled: true);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, ForeignTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Contains("not on the MCP whitelist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecycledUpn_SameTenantDifferentOid_IsDenied()
|
||||
{
|
||||
SetRow(enabled: true);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, OtherOid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncompleteIdentity_NoTidOid_IsDenied()
|
||||
{
|
||||
SetRow(enabled: true);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, null, null);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoRow_NeverConsultsBinding()
|
||||
{
|
||||
// Ordinary tenant users have no McpUsers row: one cached row read, no binding lookup, no log line.
|
||||
var probed = false;
|
||||
var bindings = new StubAdminIdentityBindingService(_ => { probed = true; return true; });
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var globalAdmin = new Mock<GlobalAdminService>(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
|
||||
var delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.WhitelistOnly.ToString() });
|
||||
var sut = new McpUserService(_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object);
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
|
||||
var result = await sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.False(probed);
|
||||
}
|
||||
|
||||
// ── kill-switch ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledRow_DeniesTheBoundIdentity()
|
||||
{
|
||||
SetRow(enabled: false);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Contains("disabled on the MCP whitelist", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledRow_DoesNotFireForAForeignSameUpnIdentity_UnderAllMembers()
|
||||
{
|
||||
// The kill-switch belongs to the bound identity: an operator's per-account disable must not deny an
|
||||
// unrelated same-UPN identity in another tenant (whose access comes from its own tenant's policy).
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var globalAdmin = new Mock<GlobalAdminService>(
|
||||
_adminRepo.Object, _bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
|
||||
var delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object, _bindings, new StubTenantEntitlementService(TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
|
||||
var sut = new McpUserService(_adminRepo.Object, _bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object);
|
||||
SetRow(enabled: false);
|
||||
|
||||
var bound = await sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
var foreign = await sut.IsAllowedAsync(Upn, ForeignTenant, Oid);
|
||||
|
||||
Assert.False(bound.IsAllowed);
|
||||
Assert.True(foreign.IsAllowed);
|
||||
Assert.Equal("AllMembers", foreign.AccessGrant);
|
||||
}
|
||||
|
||||
// ── usage-plan override ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetBoundMcpUser_ReturnsRowOnlyForTheBoundIdentity()
|
||||
{
|
||||
SetRow(enabled: true, plan: "pro");
|
||||
|
||||
Assert.NotNull(await _sut.GetBoundMcpUserAsync(Bound));
|
||||
Assert.Null(await _sut.GetBoundMcpUserAsync(AdminIdentity.Create(Upn, ForeignTenant, Oid)));
|
||||
Assert.Null(await _sut.GetBoundMcpUserAsync(AdminIdentity.Create(Upn, HomeTenant, OtherOid)));
|
||||
Assert.Null(await _sut.GetBoundMcpUserAsync(null));
|
||||
}
|
||||
|
||||
// ── grant-time binding ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AddMcpUser_BindsTheUpnBeforeWritingTheRow()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
_bindings.OnEnsureBound = () => calls.Add("bind");
|
||||
_adminRepo.Setup(x => x.AddMcpUserAsync(Upn, "ga@vendor.example"))
|
||||
.Callback(() => calls.Add("row")).ReturnsAsync(true);
|
||||
|
||||
await _sut.AddMcpUserAsync(Upn, "GA@vendor.example", HomeTenant, Oid);
|
||||
|
||||
Assert.Equal(new[] { "bind", "row" }, calls);
|
||||
Assert.Single(_bindings.Bindings);
|
||||
Assert.Equal((Upn, HomeTenant, Oid), _bindings.Bindings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMcpUser_BindingConflict_WritesNoRow()
|
||||
{
|
||||
_bindings.OnEnsureBound = () => throw new IdentityBindingConflictException("already bound elsewhere");
|
||||
|
||||
await Assert.ThrowsAsync<IdentityBindingConflictException>(
|
||||
() => _sut.AddMcpUserAsync(Upn, "ga@vendor.example", ForeignTenant, null));
|
||||
|
||||
_adminRepo.Verify(x => x.AddMcpUserAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>RecordImeVersionAsync</c> writes a DEVICE-SUPPLIED string as the RowKey of the
|
||||
/// caller-independent GLOBAL partition of ImeVersionHistory, and its "insert succeeded" is
|
||||
/// what raises the platform ops event and queues the installer archive. Any device holding a
|
||||
/// tenant agent credential can send an <c>ime_agent_version</c> event, so the write site
|
||||
/// itself must (a) refuse strings that cannot be a Windows Installer ProductVersion and
|
||||
/// (b) record when a SECOND tenant reports the version — the corroboration the tenant-facing
|
||||
/// read side requires before it lists a version to every other tenant.
|
||||
/// </summary>
|
||||
public class RecordImeVersionTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string SessionId = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
// =========================================================================
|
||||
// Guard: nothing is written for an implausible string
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("latest")]
|
||||
[InlineData("1.86.999999.0")]
|
||||
[InlineData("../escape")]
|
||||
[InlineData("1")]
|
||||
public async Task ImplausibleVersion_IsRejected_WithoutTouchingStorage(string version)
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
var sighting = await harness.Sut.RecordImeVersionAsync(version, TenantA, SessionId);
|
||||
|
||||
Assert.True(sighting.Rejected);
|
||||
Assert.False(sighting.IsNew);
|
||||
harness.History.Verify(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
harness.History.Verify(t => t.UpsertEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// First sighting
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task NewVersion_IsInserted_AsGlobalRow_WithoutCorroboration()
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
var sighting = await harness.Sut.RecordImeVersionAsync("1.105.103.0", TenantA, SessionId);
|
||||
|
||||
Assert.True(sighting.IsNew);
|
||||
Assert.False(sighting.Rejected);
|
||||
harness.History.Verify(t => t.AddEntityAsync(
|
||||
It.Is<TableEntity>(e => e.PartitionKey == "Global"
|
||||
&& e.RowKey == "1.105.103.0"
|
||||
&& e.GetString("FirstSeenTenantId") == TenantA
|
||||
&& e.GetInt32("SessionCount") == 1
|
||||
&& !e.ContainsKey("CorroboratedAt")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Known version: SessionCount + corroboration
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task KnownVersion_SameTenant_BumpsCount_ButNeverCorroborates()
|
||||
{
|
||||
// The first-seen tenant's own devices can report the version forever without it
|
||||
// ever counting as a second, independent sighting.
|
||||
var harness = new Harness(existingRow: Row(firstSeenTenantId: TenantA, sessionCount: 4));
|
||||
|
||||
var sighting = await harness.Sut.RecordImeVersionAsync("1.105.103.0", TenantA, SessionId);
|
||||
|
||||
Assert.False(sighting.IsNew);
|
||||
harness.History.Verify(t => t.UpsertEntityAsync(
|
||||
It.Is<ITableEntity>(e => ((TableEntity)e).GetInt32("SessionCount") == 5
|
||||
&& !((TableEntity)e).ContainsKey("CorroboratedAt")),
|
||||
TableUpdateMode.Merge, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownVersion_OtherTenant_StampsCorroboratedAt()
|
||||
{
|
||||
var harness = new Harness(existingRow: Row(firstSeenTenantId: TenantA, sessionCount: 4));
|
||||
|
||||
await harness.Sut.RecordImeVersionAsync("1.105.103.0", TenantB, SessionId);
|
||||
|
||||
harness.History.Verify(t => t.UpsertEntityAsync(
|
||||
It.Is<ITableEntity>(e => ((TableEntity)e).GetInt32("SessionCount") == 5
|
||||
&& ((TableEntity)e).GetDateTime("CorroboratedAt") != null),
|
||||
TableUpdateMode.Merge, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownVersion_AlreadyCorroborated_KeepsOriginalStamp()
|
||||
{
|
||||
var stamped = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var harness = new Harness(existingRow: Row(firstSeenTenantId: TenantA, sessionCount: 4, corroboratedAt: stamped));
|
||||
|
||||
await harness.Sut.RecordImeVersionAsync("1.105.103.0", TenantB, SessionId);
|
||||
|
||||
harness.History.Verify(t => t.UpsertEntityAsync(
|
||||
It.Is<ITableEntity>(e => !((TableEntity)e).ContainsKey("CorroboratedAt")),
|
||||
TableUpdateMode.Merge, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownVersion_ReturnsArchiveColumns_ForTheRequeueDecision()
|
||||
{
|
||||
var updated = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
var row = Row(firstSeenTenantId: TenantA, sessionCount: 1);
|
||||
row["MsiArchiveStatus"] = "Failed:VersionMismatch";
|
||||
row["MsiArchiveUpdatedAt"] = updated;
|
||||
var harness = new Harness(existingRow: row);
|
||||
|
||||
var sighting = await harness.Sut.RecordImeVersionAsync("1.105.103.0", TenantB, SessionId);
|
||||
|
||||
Assert.Equal("Failed:VersionMismatch", sighting.MsiArchiveStatus);
|
||||
Assert.Equal(updated, sighting.MsiArchiveUpdatedAt);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static TableEntity Row(string firstSeenTenantId, int sessionCount, DateTime? corroboratedAt = null)
|
||||
{
|
||||
var row = new TableEntity("Global", "1.105.103.0")
|
||||
{
|
||||
["FirstSeenTenantId"] = firstSeenTenantId,
|
||||
["SessionCount"] = sessionCount,
|
||||
};
|
||||
if (corroboratedAt.HasValue) row["CorroboratedAt"] = corroboratedAt.Value;
|
||||
return row;
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> History { get; }
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public Harness(TableEntity? existingRow = null)
|
||||
{
|
||||
History = new Mock<TableClient>();
|
||||
|
||||
if (existingRow is null)
|
||||
{
|
||||
History
|
||||
.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
}
|
||||
else
|
||||
{
|
||||
History
|
||||
.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(409, "EntityAlreadyExists"));
|
||||
History
|
||||
.Setup(t => t.GetEntityAsync<TableEntity>("Global", existingRow.RowKey, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(existingRow, Mock.Of<Response>()));
|
||||
History
|
||||
.Setup(t => t.UpsertEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
}
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.ImeVersionHistory)).Returns(History.Object);
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,14 @@ internal sealed class StubAdminIdentityBindingService : AdminIdentityBindingServ
|
||||
_verdict = verdict;
|
||||
}
|
||||
|
||||
/// <summary>Invoked at the start of every EnsureBoundAsync — observe call order or throw a conflict.</summary>
|
||||
public Action? OnEnsureBound { get; set; }
|
||||
|
||||
public override Task<bool> IsBoundAsync(AdminIdentity? identity) => Task.FromResult(_verdict(identity));
|
||||
|
||||
public override Task<AdminIdentityBinding> EnsureBoundAsync(string upn, string tenantId, string? objectId, string boundBy)
|
||||
{
|
||||
OnEnsureBound?.Invoke();
|
||||
Bindings.Add((upn.ToLowerInvariant(), tenantId.ToLowerInvariant(), objectId?.ToLowerInvariant()));
|
||||
return Task.FromResult(new AdminIdentityBinding
|
||||
{
|
||||
|
||||
@@ -118,13 +118,78 @@ public class UpdateSessionImeAgentVersionTests
|
||||
// No exception thrown.
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sighting signal: the return value is what makes a session count ONCE in the
|
||||
// global ImeVersionHistory (SessionCount / IsNew) — a device re-sending the event
|
||||
// for the same version must not move the global counters again.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_returns_true_when_version_is_new_for_the_session()
|
||||
{
|
||||
var harness = new Harness();
|
||||
|
||||
Assert.True(await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_returns_true_when_version_changed()
|
||||
{
|
||||
var harness = new Harness(currentVersion: "1.2.3.3");
|
||||
|
||||
Assert.True(await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_returns_false_and_writes_nothing_when_unchanged()
|
||||
{
|
||||
var harness = new Harness(currentVersion: "1.2.3.4");
|
||||
|
||||
Assert.False(await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4"));
|
||||
|
||||
harness.Sessions.Verify(t => t.UpdateEntityAsync(
|
||||
It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
harness.Index.Verify(t => t.UpdateEntityAsync(
|
||||
It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_returns_false_when_row_is_absent()
|
||||
{
|
||||
// Tombstoned / never-registered session: no sighting for the global history either.
|
||||
var harness = new Harness();
|
||||
harness.Sessions
|
||||
.Setup(t => t.GetEntityAsync<TableEntity>(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found"));
|
||||
|
||||
Assert.False(await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4"));
|
||||
|
||||
harness.Sessions.Verify(t => t.UpdateEntityAsync(
|
||||
It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSessionImeAgentVersionAsync_returns_true_on_unknown_storage_error()
|
||||
{
|
||||
// Unknown outcome counts as a sighting — a transient 503 must never drop a genuinely new version.
|
||||
var harness = new Harness();
|
||||
harness.Sessions
|
||||
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "ServiceUnavailable"));
|
||||
|
||||
Assert.True(await harness.Sut.UpdateSessionImeAgentVersionAsync(TenantId, SessionId, "1.2.3.4"));
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Sessions { get; }
|
||||
public Mock<TableClient> Index { get; }
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public Harness(string? indexRowKey = IndexRowKey)
|
||||
public Harness(string? indexRowKey = IndexRowKey, string? currentVersion = null)
|
||||
{
|
||||
Sessions = new Mock<TableClient>();
|
||||
Index = new Mock<TableClient>();
|
||||
@@ -133,10 +198,13 @@ public class UpdateSessionImeAgentVersionTests
|
||||
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
|
||||
// Re-read for IndexRowKey (select projection) after the Sessions write.
|
||||
// Point read (select projection) before the Sessions write: current ImeAgentVersion
|
||||
// decides whether this is a sighting, IndexRowKey feeds the SessionsIndex mirror.
|
||||
var indexRef = new TableEntity(TenantId, SessionId);
|
||||
if (!string.IsNullOrEmpty(indexRowKey))
|
||||
indexRef["IndexRowKey"] = indexRowKey;
|
||||
if (!string.IsNullOrEmpty(currentVersion))
|
||||
indexRef["ImeAgentVersion"] = currentVersion;
|
||||
Sessions
|
||||
.Setup(t => t.GetEntityAsync<TableEntity>(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(indexRef, Mock.Of<Response>()));
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
string? country, string? region, string? city, string? loc)
|
||||
=> _storage.UpdateSessionGeoAsync(tenantId, sessionId, country, region, city, loc);
|
||||
|
||||
public Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
|
||||
public Task<bool> UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
|
||||
=> _storage.UpdateSessionImeAgentVersionAsync(tenantId, sessionId, version);
|
||||
|
||||
public Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -15,13 +18,16 @@ public class McpUserFunction
|
||||
{
|
||||
private readonly ILogger<McpUserFunction> _logger;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
|
||||
public McpUserFunction(
|
||||
ILogger<McpUserFunction> logger,
|
||||
McpUserService mcpUserService)
|
||||
McpUserService mcpUserService,
|
||||
AdminIdentityResolver identityResolver)
|
||||
{
|
||||
_logger = logger;
|
||||
_mcpUserService = mcpUserService;
|
||||
_identityResolver = identityResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -42,9 +48,13 @@ public class McpUserFunction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/admin/mcp-users
|
||||
/// POST /api/global/mcp-users
|
||||
/// Adds a user to the MCP whitelist. GlobalAdminOnly.
|
||||
/// Body: { "upn": "user@domain.com" }
|
||||
/// Body: { "upn": "user@domain.com", "homeTenantId"?: "<guid>", "objectId"?: "<guid>" }.
|
||||
/// 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) and the body may override it —
|
||||
/// 422 HomeTenantUnresolved when neither works, 409 when the UPN is already bound elsewhere. Same
|
||||
/// contract as POST auth/global-admins and POST global/delegated-admins.
|
||||
/// </summary>
|
||||
[Function("AddMcpUser")]
|
||||
[Authorize]
|
||||
@@ -62,8 +72,32 @@ public class McpUserFunction
|
||||
await badResponse.WriteAsJsonAsync(new { error = "UPN is required" });
|
||||
return badResponse;
|
||||
}
|
||||
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
|
||||
if (bindingError != null)
|
||||
{
|
||||
var badResponse = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badResponse.WriteAsJsonAsync(new { error = bindingError });
|
||||
return badResponse;
|
||||
}
|
||||
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 user = await _mcpUserService.AddMcpUserAsync(body.Upn, currentUpn!);
|
||||
McpUserEntry user;
|
||||
try
|
||||
{
|
||||
user = await _mcpUserService.AddMcpUserAsync(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;
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.Created);
|
||||
await response.WriteAsJsonAsync(new { user });
|
||||
@@ -207,6 +241,10 @@ public class McpUserFunction
|
||||
public class AddMcpUserRequest
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
public class SetUsagePlanRequest
|
||||
|
||||
+26
@@ -60,6 +60,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
/// the archive is a platform-wide view of Microsoft's IME rollout, and the route is
|
||||
/// MemberRead precisely so any tenant member can read that view.
|
||||
///
|
||||
/// Non-global callers additionally only see versions that <see cref="IsFleetConfirmed"/>:
|
||||
/// a row is created by whatever string a device reports, and one tenant's devices must
|
||||
/// not be able to publish an unverified "version" into every other tenant's rollout view.
|
||||
/// Global scope sees every row, unconfirmed ones included — that is where a bogus claim
|
||||
/// is investigated.
|
||||
///
|
||||
/// Returned as <see cref="object"/> on purpose — System.Text.Json serializes an
|
||||
/// `object` declared type using the RUNTIME type, so both branches keep the exact
|
||||
/// wire format they had while the projection was inline.
|
||||
@@ -73,8 +79,28 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
}
|
||||
|
||||
return versions
|
||||
.Where(IsFleetConfirmed)
|
||||
.Select(v => new { v.Version, v.FirstSeenAt, v.LastSeenAt, v.SessionCount })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rows first seen before this instant predate both the version guard and the
|
||||
/// corroboration stamp. The whole table was reviewed on 2026-08-30 (16 rows, all
|
||||
/// genuine Microsoft builds), so they are trusted as-is instead of vanishing from the
|
||||
/// tenant view until a second tenant happens to report a version nobody runs any more.
|
||||
/// </summary>
|
||||
public static readonly DateTime LegacyTrustCutoffUtc = new(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// A version is confirmed for the tenant view when evidence beyond the first-seen
|
||||
/// tenant's own claim exists: the installer was archived with a matching MSI
|
||||
/// ProductVersion (<c>ImeMsiArchiver</c>), or a SECOND tenant reported it
|
||||
/// (<see cref="ImeVersionHistoryEntry.CorroboratedAt"/>), or the row predates the guard.
|
||||
/// </summary>
|
||||
public static bool IsFleetConfirmed(ImeVersionHistoryEntry entry) =>
|
||||
string.Equals(entry.MsiArchiveStatus, Services.Ime.ImeMsiArchiver.Statuses.Archived, StringComparison.Ordinal)
|
||||
|| entry.CorroboratedAt.HasValue
|
||||
|| entry.FirstSeenAt < LegacyTrustCutoffUtc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -58,11 +59,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var dateTo = req.Query["dateTo"];
|
||||
|
||||
var records = await _userUsageRepo.GetUsageByUserAsync(userId, dateFrom, dateTo);
|
||||
var mcpUser = !string.IsNullOrEmpty(upn) ? await _mcpUserService.GetMcpUserAsync(upn) : null;
|
||||
// The caller's OWN whitelist row (tid + oid bound) — a same-UPN identity from another
|
||||
// tenant must not see, or be granted, someone else's per-user plan override.
|
||||
var tenantId = principal?.GetTenantId();
|
||||
var mcpUser = await _mcpUserService.GetBoundMcpUserAsync(AdminIdentity.Create(upn, tenantId, userId));
|
||||
|
||||
// Effective quota state: resolved plan (per-user override → tenant edition),
|
||||
// limits (SectionUsagePlans definition → catalog fallback) and current counters.
|
||||
var tenantId = principal?.GetTenantId();
|
||||
var quota = await _quotaService.CheckAsync(userId, upn, tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
@@ -6,47 +6,72 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Daily rule-fire/evaluation telemetry (per-tenant + global aggregate rows).
|
||||
/// Both methods are fire-and-forget — failures are logged, never thrown.
|
||||
/// Fire-and-forget — failures are logged, never thrown.
|
||||
/// </summary>
|
||||
public sealed partial class EventIngestProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the gather-rule fires claimed by a telemetry batch against the tenant's active
|
||||
/// gather catalog. Only the event's <c>ruleId</c> is read; every other field (title, category,
|
||||
/// severity, built-in flag) comes from the server-side rule definition — mirrors
|
||||
/// <c>AnalyzeOnEnrollmentEndHandler.SafeRecordAnalyzeRuleStatsAsync</c>, where the catalog rule
|
||||
/// object is the sole source of RuleStats metadata. A claimed ID that is not an active rule for
|
||||
/// this tenant is dropped: an agent cannot mint stats rows for retired/unshipped IDs or respell a
|
||||
/// built-in rule's title. Deduped per batch (one increment per rule).
|
||||
/// </summary>
|
||||
internal static List<GatherRule> ResolveFiredGatherRules(
|
||||
IEnumerable<EnrollmentEvent> events, IReadOnlyCollection<GatherRule> activeRules)
|
||||
{
|
||||
var byId = new Dictionary<string, GatherRule>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var rule in activeRules)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(rule.RuleId))
|
||||
byId.TryAdd(rule.RuleId, rule);
|
||||
}
|
||||
|
||||
var fired = new List<GatherRule>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in events)
|
||||
{
|
||||
if (evt.Source != "GatherRuleExecutor" || evt.Data == null) continue;
|
||||
if (!evt.Data.TryGetValue("ruleId", out var raw)) continue;
|
||||
|
||||
var ruleId = raw?.ToString();
|
||||
if (string.IsNullOrEmpty(ruleId) || !seen.Add(ruleId)) continue;
|
||||
if (byId.TryGetValue(ruleId, out var rule))
|
||||
fired.Add(rule);
|
||||
}
|
||||
return fired;
|
||||
}
|
||||
|
||||
private async Task RecordGatherRuleStatsAsync(string tenantId, List<EnrollmentEvent> events)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!events.Any(e => e.Source == "GatherRuleExecutor" && e.Data != null && e.Data.ContainsKey("ruleId")))
|
||||
return;
|
||||
|
||||
var activeRules = await _gatherRuleService.GetActiveRulesForTenantAsync(tenantId).ConfigureAwait(false);
|
||||
var firedRules = ResolveFiredGatherRules(events, activeRules);
|
||||
if (firedRules.Count == 0) return;
|
||||
|
||||
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
|
||||
var gatherEvents = events.Where(e =>
|
||||
e.Data != null &&
|
||||
e.Data.ContainsKey("ruleId") &&
|
||||
e.Source == "GatherRuleExecutor").ToList();
|
||||
|
||||
if (gatherEvents.Count == 0) return;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in gatherEvents)
|
||||
foreach (var rule in firedRules)
|
||||
{
|
||||
var ruleId = evt.Data!["ruleId"]?.ToString();
|
||||
if (string.IsNullOrEmpty(ruleId) || !seen.Add(ruleId)) continue;
|
||||
|
||||
var ruleTitle = evt.Data.ContainsKey("ruleTitle") ? evt.Data["ruleTitle"]?.ToString() ?? "" : "";
|
||||
|
||||
await _metricsRepo.IncrementRuleStatAsync(
|
||||
today, tenantId, ruleId, "gather",
|
||||
ruleTitle, "", "",
|
||||
fired: true, confidenceScore: null);
|
||||
today, tenantId, rule.RuleId, "gather",
|
||||
rule.Title, rule.Category, rule.OutputSeverity,
|
||||
fired: true, confidenceScore: null).ConfigureAwait(false);
|
||||
|
||||
// Global aggregate row is catalog-only. The agent event carries no
|
||||
// IsBuiltIn flag, but custom rules can never occupy the reserved
|
||||
// built-in namespace (create/update guard), so the ID pattern is the
|
||||
// discriminator. Custom IDs are tenant-chosen and not unique across
|
||||
// tenants — a shared "global_{ruleId}" row would mix their counters.
|
||||
if (RuleIdPolicy.IsReservedBuiltInId(ruleId))
|
||||
// Global aggregate row is catalog-only: custom-rule IDs are tenant-chosen
|
||||
// and not unique across tenants, so a shared "global_{ruleId}" row would
|
||||
// sum unrelated tenants' counters (title/severity last-writer-wins).
|
||||
if (rule.IsBuiltIn || rule.IsCommunity)
|
||||
{
|
||||
await _metricsRepo.IncrementRuleStatAsync(
|
||||
today, "global", ruleId, "gather",
|
||||
ruleTitle, "", "",
|
||||
fired: true, confidenceScore: null);
|
||||
today, "global", rule.RuleId, "gather",
|
||||
rule.Title, rule.Category, rule.OutputSeverity,
|
||||
fired: true, confidenceScore: null).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +80,5 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_logger.LogWarning(ex, "Failed to record gather rule stats (non-fatal)");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AnalyzeRuleService _analyzeRuleService;
|
||||
private readonly GatherRuleService _gatherRuleService;
|
||||
private readonly WebhookNotificationService _webhookNotificationService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly OpsEventService _opsEventService;
|
||||
@@ -58,6 +59,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
IRuleRepository ruleRepo,
|
||||
TenantConfigurationService configService,
|
||||
AnalyzeRuleService analyzeRuleService,
|
||||
GatherRuleService gatherRuleService,
|
||||
WebhookNotificationService webhookNotificationService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
OpsEventService opsEventService,
|
||||
@@ -75,6 +77,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_ruleRepo = ruleRepo;
|
||||
_configService = configService;
|
||||
_analyzeRuleService = analyzeRuleService;
|
||||
_gatherRuleService = gatherRuleService;
|
||||
_webhookNotificationService = webhookNotificationService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_opsEventService = opsEventService;
|
||||
@@ -136,10 +139,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var imeVersion = imeVersionEvent.Data!["agentVersion"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(imeVersion))
|
||||
{
|
||||
_ = _sessionRepo.UpdateSessionImeAgentVersionAsync(request.TenantId, request.SessionId, imeVersion)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"ImeAgentVersion update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
|
||||
// Captured here (not inside the continuation) purely for clarity — the agent's
|
||||
// CSP-registry enrichment; absent on agent builds before 2026-08-17.
|
||||
string? msiDownloadUrl = null, msiMatchedBy = null;
|
||||
@@ -148,36 +147,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (imeVersionEvent.Data.TryGetValue("msiMatchedBy", out var matchedByObj))
|
||||
msiMatchedBy = matchedByObj?.ToString();
|
||||
|
||||
_ = _sessionRepo.RecordImeVersionAsync(imeVersion, request.TenantId, request.SessionId)
|
||||
// The GLOBAL version history counts a session once: only when the session's
|
||||
// stored ImeAgentVersion actually changed does this batch become a sighting.
|
||||
// A re-sent (or replayed) event for the same version stops here.
|
||||
_ = _sessionRepo.UpdateSessionImeAgentVersionAsync(request.TenantId, request.SessionId, imeVersion)
|
||||
.ContinueWith(async t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
{
|
||||
_logger.LogWarning(t.Exception?.InnerException,
|
||||
"ImeVersionHistory update failed (non-fatal)");
|
||||
}
|
||||
else if (t.Result?.IsNew == true)
|
||||
{
|
||||
await _opsEventService.RecordNewImeVersionDetectedAsync(
|
||||
imeVersion, request.TenantId, request.SessionId);
|
||||
|
||||
// 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 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);
|
||||
"ImeAgentVersion update failed (non-fatal)");
|
||||
return;
|
||||
}
|
||||
if (!t.Result) return;
|
||||
await RecordImeVersionSightingAsync(imeVersion, msiDownloadUrl, msiMatchedBy, request);
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
@@ -493,6 +476,52 @@ namespace AutopilotMonitor.Functions.Services
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One sighting of <paramref name="imeVersion"/> by this session against the global
|
||||
/// version history. The repository rejects implausible strings before writing
|
||||
/// (<see cref="ImeVersionSighting.Rejected"/> → nothing else happens); a first
|
||||
/// fleet-wide sighting raises the ops event and queues the installer archive; a known,
|
||||
/// not-yet-archived version may be re-queued when this session brings an authoritative
|
||||
/// CSP URL. Fail-soft: runs on the fire-and-forget continuation of the session stamp.
|
||||
/// </summary>
|
||||
private async Task RecordImeVersionSightingAsync(
|
||||
string imeVersion, string? msiDownloadUrl, string? msiMatchedBy, IngestEventsRequest request)
|
||||
{
|
||||
ImeVersionSighting? sighting;
|
||||
try
|
||||
{
|
||||
sighting = await _sessionRepo.RecordImeVersionAsync(imeVersion, request.TenantId, request.SessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ImeVersionHistory update failed (non-fatal)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (sighting?.IsNew == true)
|
||||
{
|
||||
await _opsEventService.RecordNewImeVersionDetectedAsync(
|
||||
imeVersion, request.TenantId, request.SessionId);
|
||||
|
||||
// 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 EnqueueImeMsiArchiveAsync(imeVersion, msiDownloadUrl, msiMatchedBy, request);
|
||||
}
|
||||
else if (ImeMsiArchiver.ShouldRequeueOnSighting(sighting, 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session-wide API latency projection: the agent emits cumulative counters
|
||||
/// (net_total_latency_ms / net_total_requests) in every agent_metrics_snapshot, so
|
||||
|
||||
@@ -98,6 +98,24 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
/// <summary>Blob folder name = version — must be plain dotted digits (untrusted input).</summary>
|
||||
internal static readonly Regex VersionRegex = new(@"^\d+(\.\d+){1,3}$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// The version guard for everything keyed by a device-reported IME version: the
|
||||
/// GLOBAL <c>ImeVersionHistory</c> row (RowKey), the ops event and the archive folder.
|
||||
/// Plain dotted digits with 2–4 components (<see cref="VersionRegex"/>), and every
|
||||
/// component inside the range a Windows Installer package can actually carry as its
|
||||
/// <c>ProductVersion</c> (major/minor ≤ 255, build ≤ 65535; the fourth field is
|
||||
/// ignored by MSI, bounded to 65535 here so a 20-digit tail cannot pass either).
|
||||
/// A string that fails this can never be the ProductVersion of a real IME package,
|
||||
/// so nothing downstream should exist for it.
|
||||
/// </summary>
|
||||
public static bool IsPlausibleVersion(string? version)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version) || version.Length > 20) return false;
|
||||
if (!VersionRegex.IsMatch(version)) return false;
|
||||
if (!Version.TryParse(version, out var v)) return false;
|
||||
return v.Major <= 255 && v.Minor <= 255 && v.Build <= 65535 && v.Revision <= 65535;
|
||||
}
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly BlobStorageService _blobStorage;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
@@ -189,7 +207,7 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
public static bool ShouldRequeueOnSighting(
|
||||
ImeVersionSighting? sighting, string? msiDownloadUrl, string? msiMatchedBy, DateTime nowUtc)
|
||||
{
|
||||
if (sighting is null || sighting.IsNew) return false;
|
||||
if (sighting is null || sighting.IsNew || sighting.Rejected) return false;
|
||||
if (!IsAllowedMsiUrl(msiDownloadUrl)) return false;
|
||||
if (!string.Equals(msiMatchedBy, MatchedByProductVersion, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
@@ -204,7 +222,7 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
public virtual async Task<ImeMsiArchiveResult> ArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (envelope is null || !VersionRegex.IsMatch(envelope.Version ?? string.Empty))
|
||||
if (envelope is null || !IsPlausibleVersion(envelope.Version))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImeMsiArchiver: rejecting version {Version} — not a plain dotted version string",
|
||||
|
||||
@@ -12,7 +12,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Resolves and enforces the per-user MCP request quota (daily + monthly).
|
||||
///
|
||||
/// Plan precedence: explicit per-user override (McpUsers.UsagePlan) → the caller's home-tenant
|
||||
/// Plan precedence: explicit per-user override (McpUsers.UsagePlan — honoured only for the identity
|
||||
/// the UPN is bound to, tid + oid, see McpUserService.GetBoundMcpUserAsync) → the caller's home-tenant
|
||||
/// edition default (FeatureEntitlementCatalog.McpUsagePlanName). Limits come from the
|
||||
/// admin-editable SectionUsagePlans definitions (AdminConfiguration.PlanTierDefinitionsJson);
|
||||
/// when no definition matches the plan name, the static catalog fallbacks apply. An override
|
||||
@@ -77,7 +78,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (_cache.TryGetValue<McpQuotaDecision>(cacheKey, out var cached) && cached != null)
|
||||
return cached;
|
||||
|
||||
var (planName, dailyLimit, monthlyLimit) = await ResolvePlanAsync(upn, tenantId);
|
||||
var (planName, dailyLimit, monthlyLimit) = await ResolvePlanAsync(AdminIdentity.Create(upn, tenantId, oid), tenantId);
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
@@ -106,21 +107,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
/// <summary>
|
||||
/// Plan resolution only (no counter read) — used by the self-service usage endpoint.
|
||||
/// <paramref name="identity"/> is the caller's validated (upn, tid, oid); the per-user override applies
|
||||
/// only when that identity IS the one the McpUsers UPN is bound to — a null / unbound identity gets the
|
||||
/// tenant edition default.
|
||||
/// </summary>
|
||||
public virtual async Task<(string PlanName, int DailyLimit, int MonthlyLimit)> ResolvePlanAsync(string? upn, string? tenantId)
|
||||
public virtual async Task<(string PlanName, int DailyLimit, int MonthlyLimit)> ResolvePlanAsync(AdminIdentity? identity, string? tenantId)
|
||||
{
|
||||
// 1. Per-user override wins when set.
|
||||
// 1. Per-user override wins when set — for the BOUND identity only.
|
||||
string? overridePlan = null;
|
||||
if (!string.IsNullOrWhiteSpace(upn))
|
||||
if (identity != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mcpUser = await _mcpUserService.GetMcpUserAsync(upn.ToLowerInvariant());
|
||||
var mcpUser = await _mcpUserService.GetBoundMcpUserAsync(identity);
|
||||
overridePlan = string.IsNullOrWhiteSpace(mcpUser?.UsagePlan) ? null : mcpUser!.UsagePlan!.Trim().ToLowerInvariant();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpQuota] McpUser lookup failed for {Upn} — falling back to tenant edition plan", upn);
|
||||
_logger.LogWarning(ex, "[McpQuota] McpUser lookup failed for {Upn} — falling back to tenant edition plan", identity.Upn);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,18 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// - AllMembers: any authenticated user
|
||||
/// An EXPLICITLY DISABLED McpUsers row is an operator kill-switch that denies access under every
|
||||
/// policy and every grant path (platform role, delegated auto-grant, AllMembers).
|
||||
/// <para>
|
||||
/// The McpUsers row is looked up by UPN but is honoured (grant, usage-plan override AND kill-switch) only
|
||||
/// for the identity the UPN is bound to in <see cref="AdminIdentityBindingService"/> — the caller's validated
|
||||
/// JWT tid + oid must match. The API accepts tokens from every Entra tenant and upn/preferred_username are
|
||||
/// mutable, so a foreign-tenant (or recycled) token carrying a whitelisted UPN string resolves to NO row.
|
||||
/// Rows without a binding are inert, exactly like GlobalAdmins rows (no grandfathering).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class McpUserService
|
||||
{
|
||||
private readonly IAdminRepository _adminRepo;
|
||||
private readonly AdminIdentityBindingService _bindings;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<McpUserService> _logger;
|
||||
private readonly GlobalAdminService _globalAdminService;
|
||||
@@ -34,6 +42,7 @@ public class McpUserService
|
||||
|
||||
public McpUserService(
|
||||
IAdminRepository adminRepo,
|
||||
AdminIdentityBindingService bindings,
|
||||
IMemoryCache cache,
|
||||
ILogger<McpUserService> logger,
|
||||
GlobalAdminService globalAdminService,
|
||||
@@ -41,6 +50,7 @@ public class McpUserService
|
||||
AdminConfigurationService adminConfigService)
|
||||
{
|
||||
_adminRepo = adminRepo;
|
||||
_bindings = bindings;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_globalAdminService = globalAdminService;
|
||||
@@ -52,9 +62,10 @@ public class McpUserService
|
||||
/// Checks if a user is allowed to access the MCP server based on current policy.
|
||||
/// <paramref name="homeTenantId"/> and <paramref name="objectId"/> are the caller's JWT tid and oid:
|
||||
/// together with the UPN they form the <see cref="AdminIdentity"/> the platform-role and delegated
|
||||
/// (MSP) grant paths resolve on — both are keyed on the identity binding, never on the UPN alone, and
|
||||
/// the delegated path additionally requires a Pro home tenant. Null tid/oid fails closed to no platform
|
||||
/// role and an empty delegated scope; the whitelist (McpUsers) and AllMembers paths are unaffected.
|
||||
/// (MSP) grant paths AND the McpUsers whitelist resolve on — all three are keyed on the identity binding,
|
||||
/// never on the UPN alone, and the delegated path additionally requires a Pro home tenant. Null tid/oid
|
||||
/// fails closed to no platform role, an empty delegated scope and no whitelist row; only the AllMembers
|
||||
/// path (tenant-bound by the auth middleware itself) is unaffected.
|
||||
/// </summary>
|
||||
public virtual async Task<McpAccessCheckResult> IsAllowedAsync(string? upn, string? homeTenantId, string? objectId)
|
||||
{
|
||||
@@ -82,7 +93,8 @@ public class McpUserService
|
||||
var delegatedRole = delegatedScope.IsEmpty ? null : StrongestDelegatedRole(delegatedScope);
|
||||
|
||||
// Resolve the caller's McpUsers whitelist state ONCE (cached): NotPresent / Enabled / Disabled.
|
||||
var whitelistState = await ResolveWhitelistStateAsync(upn);
|
||||
// A row the caller's tid/oid are not bound to is NotPresent for them.
|
||||
var whitelistState = await ResolveWhitelistStateAsync(identity);
|
||||
|
||||
// SECURITY (operator kill-switch): an EXPLICITLY DISABLED McpUsers row denies MCP access under
|
||||
// EVERY policy and EVERY grant path — platform roles, the delegated (MSP) auto-grant, and
|
||||
@@ -134,11 +146,19 @@ public class McpUserService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<McpUserEntry> AddMcpUserAsync(string upn, string addedBy)
|
||||
/// <summary>
|
||||
/// Whitelists a UPN, binding it to its home tenant (and object id, when known) FIRST — a row without a
|
||||
/// binding is inert, and a binding conflict (UPN already homed elsewhere) aborts before any row is written.
|
||||
/// </summary>
|
||||
/// <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<McpUserEntry> AddMcpUserAsync(string upn, string addedBy, string homeTenantId, string? objectId)
|
||||
{
|
||||
upn = upn.ToLowerInvariant();
|
||||
addedBy = addedBy.ToLowerInvariant();
|
||||
|
||||
await _bindings.EnsureBoundAsync(upn, homeTenantId, objectId, addedBy);
|
||||
await _adminRepo.AddMcpUserAsync(upn, addedBy);
|
||||
_cache.Remove($"mcp-user:{upn}");
|
||||
|
||||
@@ -171,12 +191,29 @@ public class McpUserService
|
||||
_logger.LogInformation("MCP user {Action}: {Upn}", isEnabled ? "enabled" : "disabled", upn);
|
||||
}
|
||||
|
||||
/// <summary>Operator view of a whitelist row by UPN — NOT an authorization read (see <see cref="GetBoundMcpUserAsync"/>).</summary>
|
||||
public async Task<McpUserEntry?> GetMcpUserAsync(string upn)
|
||||
{
|
||||
upn = upn.ToLowerInvariant();
|
||||
return await _adminRepo.GetMcpUserAsync(upn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The McpUsers row that belongs to the CALLER: the row keyed on the identity's UPN, but only when the
|
||||
/// caller's tid/oid match the UPN's identity binding — otherwise null, even if a row exists. This is the
|
||||
/// read every caller-scoped consumer (usage-plan override, self-service usage view) must use; a plan
|
||||
/// tier provisioned for one organization's user must never be inherited by a same-UPN identity elsewhere.
|
||||
/// </summary>
|
||||
public virtual async Task<McpUserEntry?> GetBoundMcpUserAsync(AdminIdentity? identity)
|
||||
{
|
||||
if (identity == null)
|
||||
return null;
|
||||
var entry = await _adminRepo.GetMcpUserAsync(identity.Upn);
|
||||
if (entry == null)
|
||||
return null;
|
||||
return await _bindings.IsBoundAsync(identity) ? entry : null;
|
||||
}
|
||||
|
||||
public async Task<List<McpUserEntry>> GetAllMcpUsersAsync()
|
||||
{
|
||||
return await _adminRepo.GetAllMcpUsersAsync();
|
||||
@@ -215,13 +252,29 @@ public class McpUserService
|
||||
private enum McpWhitelistState { NotPresent, Enabled, Disabled }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves (and briefly caches) the caller's McpUsers whitelist state: NotPresent (no row),
|
||||
/// Enabled (row + IsEnabled), or Disabled (row + !IsEnabled). A Disabled row is an operator
|
||||
/// kill-switch that denies access under EVERY policy and grant path — see <see cref="IsAllowedAsync"/>.
|
||||
/// Cached under the same key the mutation methods (Add/Remove/SetEnabled) invalidate, so an
|
||||
/// enable/disable self-heals within the TTL (and immediately on the mutating instance).
|
||||
/// Resolves the caller's McpUsers whitelist state: NotPresent (no row, or a row the caller is not the
|
||||
/// bound identity for), Enabled (row + IsEnabled), or Disabled (row + !IsEnabled). A Disabled row is an
|
||||
/// operator kill-switch that denies access under EVERY policy and grant path — see <see cref="IsAllowedAsync"/>.
|
||||
/// Order mirrors <see cref="GlobalAdminService.GetGlobalRoleAsync"/>: the ROW is read by UPN (cached
|
||||
/// briefly under the key the mutation methods invalidate, so an enable/disable self-heals within the TTL);
|
||||
/// the identity binding is consulted only when a row exists, so ordinary callers cost one cached read
|
||||
/// and never produce a binding log line. An incomplete identity (no tid/oid) or an unbound/foreign
|
||||
/// caller sees NotPresent: the grant AND the kill-switch belong to the bound identity alone.
|
||||
/// </summary>
|
||||
private async Task<McpWhitelistState> ResolveWhitelistStateAsync(string upn)
|
||||
private async Task<McpWhitelistState> ResolveWhitelistStateAsync(AdminIdentity? identity)
|
||||
{
|
||||
if (identity == null)
|
||||
return McpWhitelistState.NotPresent;
|
||||
|
||||
var rowState = await ResolveRowStateAsync(identity.Upn);
|
||||
if (rowState == McpWhitelistState.NotPresent)
|
||||
return McpWhitelistState.NotPresent;
|
||||
|
||||
return await _bindings.IsBoundAsync(identity) ? rowState : McpWhitelistState.NotPresent;
|
||||
}
|
||||
|
||||
/// <summary>The state the McpUsers ROW carries for a UPN, ignoring identity binding. Cached briefly.</summary>
|
||||
private async Task<McpWhitelistState> ResolveRowStateAsync(string upn)
|
||||
{
|
||||
var cacheKey = $"mcp-user:{upn}";
|
||||
if (_cache.TryGetValue<McpWhitelistState>(cacheKey, out var cached))
|
||||
|
||||
@@ -3066,11 +3066,22 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// the correct no-op outcome.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
|
||||
public async Task<bool> UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
|
||||
// One point read serves two purposes: the current version decides whether this
|
||||
// batch is a NEW sighting for the session (the agent re-sends ime_agent_version
|
||||
// per IME log pass, and a device could replay it at will — the global history's
|
||||
// SessionCount must not move for the same session twice), and IndexRowKey is
|
||||
// needed for the SessionsIndex mirror below. 404 → row absent → no-op.
|
||||
var current = await tableClient.GetEntityAsync<TableEntity>(
|
||||
tenantId, sessionId, select: new[] { "IndexRowKey", "ImeAgentVersion" });
|
||||
if (string.Equals(current.Value.GetString("ImeAgentVersion"), version, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var entity = new TableEntity(tenantId, sessionId)
|
||||
{
|
||||
["ImeAgentVersion"] = version
|
||||
@@ -3080,25 +3091,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Mirror to SessionsIndex — ImeAgentVersion is a search-filterable index column
|
||||
// (search/MCP push an OData filter on it against the index), so it must be kept in
|
||||
// sync or the filter matches nothing and the listed value is always empty.
|
||||
var idxRef = await tableClient.GetEntityAsync<TableEntity>(
|
||||
tenantId, sessionId, select: new[] { "IndexRowKey" });
|
||||
var indexRowKey = idxRef.Value.GetString("IndexRowKey");
|
||||
var indexRowKey = current.Value.GetString("IndexRowKey");
|
||||
if (!string.IsNullOrEmpty(indexRowKey))
|
||||
{
|
||||
await MergeSessionIndexAsync(tenantId, indexRowKey,
|
||||
new TableEntity(tenantId, indexRowKey) { ["ImeAgentVersion"] = version });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"UpdateSessionImeAgentVersion no-op: Sessions row {Tenant}/{Session} is absent (tombstoned or never registered)",
|
||||
tenantId, sessionId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal — don't block ingest
|
||||
// Non-fatal — don't block ingest. Unknown outcome counts as a sighting: a
|
||||
// transient storage error must never silently drop a genuinely new version.
|
||||
_logger.LogWarning(ex, "Failed to update ImeAgentVersion for session {SessionId}", sessionId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3556,9 +3569,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// If already known, updates LastSeenAt and increments SessionCount via Merge and
|
||||
/// returns the row's archive status/timestamp (read in the same point read as
|
||||
/// SessionCount) so the ingest path can decide on an installer-archive re-queue.
|
||||
/// <para>
|
||||
/// The version string is DEVICE-SUPPLIED and becomes the RowKey of a caller-independent
|
||||
/// GLOBAL partition that every tenant reads, so it is gated by
|
||||
/// <see cref="Ime.ImeMsiArchiver.IsPlausibleVersion"/> before anything is written
|
||||
/// (<see cref="ImeVersionSighting.Rejected"/>). The merge path additionally stamps
|
||||
/// <c>CorroboratedAt</c> on the first sighting from a tenant other than the one that
|
||||
/// inserted the row: together with the archiver's ProductVersion check this is what
|
||||
/// separates "one tenant's device claimed it" from "the fleet has it" for the tenant
|
||||
/// read side (<c>GetImeVersionHistoryFunction</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<ImeVersionSighting> RecordImeVersionAsync(string version, string tenantId, string sessionId)
|
||||
{
|
||||
if (!Ime.ImeMsiArchiver.IsPlausibleVersion(version))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImeVersionHistory: rejected implausible IME version {Version} reported by session {SessionId} (tenant {TenantId})",
|
||||
version, sessionId, tenantId);
|
||||
return new ImeVersionSighting { Rejected = true };
|
||||
}
|
||||
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeVersionHistory);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -3587,7 +3618,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
try
|
||||
{
|
||||
var existing = await tableClient.GetEntityAsync<TableEntity>("Global", version,
|
||||
select: new[] { "SessionCount", "MsiArchiveStatus", "MsiArchiveUpdatedAt" });
|
||||
select: new[] { "SessionCount", "MsiArchiveStatus", "MsiArchiveUpdatedAt", "FirstSeenTenantId", "CorroboratedAt" });
|
||||
var currentCount = existing.Value.GetInt32("SessionCount") ?? 0;
|
||||
sighting.MsiArchiveStatus = existing.Value.GetString("MsiArchiveStatus");
|
||||
sighting.MsiArchiveUpdatedAt = existing.Value.GetDateTime("MsiArchiveUpdatedAt");
|
||||
@@ -3597,6 +3628,17 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["LastSeenAt"] = now,
|
||||
["SessionCount"] = currentCount + 1
|
||||
};
|
||||
|
||||
// First sighting from a SECOND tenant → corroborated. The first-seen tenant's own
|
||||
// devices can report the version as often as they like without ever reaching this.
|
||||
var firstSeenTenantId = existing.Value.GetString("FirstSeenTenantId");
|
||||
if (existing.Value.GetDateTime("CorroboratedAt") is null
|
||||
&& !string.IsNullOrEmpty(firstSeenTenantId)
|
||||
&& !string.Equals(firstSeenTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mergeEntity["CorroboratedAt"] = now;
|
||||
}
|
||||
|
||||
await tableClient.UpsertEntityAsync(mergeEntity, TableUpdateMode.Merge);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -3630,6 +3672,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
FirstSeenTenantId = entity.GetString("FirstSeenTenantId") ?? string.Empty,
|
||||
LastSeenAt = entity.GetDateTime("LastSeenAt") ?? DateTime.MinValue,
|
||||
SessionCount = entity.GetInt32("SessionCount") ?? 0,
|
||||
CorroboratedAt = entity.GetDateTime("CorroboratedAt"),
|
||||
MsiArchiveStatus = entity.GetString("MsiArchiveStatus"),
|
||||
MsiArchiveUpdatedAt = entity.GetDateTime("MsiArchiveUpdatedAt"),
|
||||
MsiArchiveBlobPath = entity.GetString("MsiArchiveBlobPath"),
|
||||
|
||||
@@ -135,7 +135,15 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
SessionStatus? status = null, bool? isUserDriven = null, string? verdictPath = null);
|
||||
Task UpdateSessionGeoAsync(string tenantId, string sessionId,
|
||||
string? country, string? region, string? city, string? loc);
|
||||
Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version);
|
||||
/// <summary>
|
||||
/// Stamps the session's IME agent version. Returns true when the session's stored
|
||||
/// version actually changed (first report, or a different version) — the signal that
|
||||
/// makes this session count as ONE sighting in the global version history. False when
|
||||
/// the session already carries this version (re-sent event) or the row is absent
|
||||
/// (tombstoned / never registered); a storage failure other than 404 counts as changed
|
||||
/// so a transient error never drops a genuine sighting.
|
||||
/// </summary>
|
||||
Task<bool> UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version);
|
||||
/// <summary>
|
||||
/// Persists the session-wide average agent→backend HTTP latency derived from the
|
||||
/// cumulative counters of the latest <c>agent_metrics_snapshot</c>. Last-write-wins
|
||||
|
||||
@@ -636,6 +636,15 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public DateTime LastSeenAt { get; set; }
|
||||
public int SessionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC time of the first sighting from a tenant OTHER than <see cref="FirstSeenTenantId"/>;
|
||||
/// null while only the first-seen tenant has reported the version. A version is shown
|
||||
/// to ordinary tenant members only once it is corroborated this way or its installer
|
||||
/// was archived with a matching ProductVersion — a single tenant's devices (the least
|
||||
/// trusted principal) cannot publish a version to every other tenant on their own.
|
||||
/// </summary>
|
||||
public DateTime? CorroboratedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the automatic installer archiving (<c>ImeMsiArchiver</c>):
|
||||
/// <c>Archived</c>, <c>Queued</c> (re-queued by a later sighting) or a <c>Failed:*</c>
|
||||
@@ -671,6 +680,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>True when this sighting inserted the version (first fleet-wide sighting).</summary>
|
||||
public bool IsNew { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the reported string failed the version guard and nothing was written:
|
||||
/// no row, and the caller must emit no ops event and queue no archive job for it.
|
||||
/// </summary>
|
||||
public bool Rejected { get; set; }
|
||||
|
||||
/// <summary>Current <c>MsiArchiveStatus</c> of the row (null when new or never archived).</summary>
|
||||
public string? MsiArchiveStatus { get; set; }
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function ProgressPortalPage() {
|
||||
Device Setup Progress
|
||||
</h1>
|
||||
<p className="text-gray-500">
|
||||
Enter your device serial number to check status
|
||||
Check the setup status of your device
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -213,14 +213,15 @@ export default function ProgressPortalPage() {
|
||||
value={serialInput}
|
||||
onChange={(e) => setSerialInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter serial number or device name (Cloud PC: the name shown in the Windows App)..."
|
||||
placeholder="Serial number or device name..."
|
||||
title="Windows 365 Cloud PC: enter the device name shown in the Windows App."
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 text-lg"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={searchBySerial}
|
||||
disabled={searching || !serialInput.trim()}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium whitespace-nowrap"
|
||||
>
|
||||
{searching ? "Searching..." : "Check Status"}
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAuth } from "../../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../../contexts/NotificationContext";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { api } from "@/lib/api";
|
||||
import { useTenantList } from "@/hooks/useTenantList";
|
||||
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
|
||||
|
||||
interface McpUser {
|
||||
upn: string;
|
||||
@@ -43,6 +45,11 @@ export default function McpUsersSection() {
|
||||
const [policy, setPolicy] = useState<McpPolicy>("WhitelistOnly");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
// Only shown after the backend answered 422 HomeTenantUnresolved (UPN never signed in AND its domain
|
||||
// belongs to no onboarded tenant) — the one case the identity binding cannot be resolved automatically.
|
||||
const [needHomeTenant, setNeedHomeTenant] = useState(false);
|
||||
const [homeTenantPick, setHomeTenantPick] = useState("");
|
||||
const tenants = useTenantList(needHomeTenant);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removingUpn, setRemovingUpn] = useState<string | null>(null);
|
||||
const [togglingUpn, setTogglingUpn] = useState<string | null>(null);
|
||||
@@ -131,7 +138,7 @@ export default function McpUsersSection() {
|
||||
}, [getAccessToken, addNotification]);
|
||||
|
||||
const handleAddUser = useCallback(async () => {
|
||||
if (!newEmail.trim()) return;
|
||||
if (!newEmail.trim() || (needHomeTenant && !homeTenantPick)) return;
|
||||
try {
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
@@ -140,16 +147,24 @@ export default function McpUsersSection() {
|
||||
const response = await authenticatedFetch(api.mcpUsers.add(), getAccessToken, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ upn: newEmail.trim() }),
|
||||
body: JSON.stringify({
|
||||
upn: newEmail.trim(),
|
||||
homeTenantId: needHomeTenant ? homeTenantPick : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED) {
|
||||
setNeedHomeTenant(true);
|
||||
}
|
||||
throw new Error(data.error || `Failed to add user: ${response.statusText}`);
|
||||
}
|
||||
|
||||
setSuccessMessage(`MCP user ${newEmail} added successfully!`);
|
||||
setNewEmail("");
|
||||
setNeedHomeTenant(false);
|
||||
setHomeTenantPick("");
|
||||
await fetchMcpUsers();
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (err) {
|
||||
@@ -161,7 +176,7 @@ export default function McpUsersSection() {
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}, [newEmail, getAccessToken, addNotification, fetchMcpUsers]);
|
||||
}, [newEmail, needHomeTenant, homeTenantPick, getAccessToken, addNotification, fetchMcpUsers]);
|
||||
|
||||
const handleRemoveUser = useCallback(async (upn: string) => {
|
||||
if (!confirm(`Remove ${upn} from MCP users?`)) return;
|
||||
@@ -337,9 +352,24 @@ export default function McpUsersSection() {
|
||||
autoComplete="off"
|
||||
className="flex-1 min-w-0 px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
|
||||
/>
|
||||
{needHomeTenant && (
|
||||
<select
|
||||
value={homeTenantPick}
|
||||
onChange={(e) => setHomeTenantPick(e.target.value)}
|
||||
className="flex-1 min-w-0 px-3 py-2 border border-amber-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-purple-500 transition-colors"
|
||||
aria-label="Home tenant of the person"
|
||||
>
|
||||
<option value="">Home tenant they sign in from…</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.tenantId} value={t.tenantId}>
|
||||
{t.domainName || t.tenantId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={handleAddUser}
|
||||
disabled={adding || !newEmail.trim()}
|
||||
disabled={adding || !newEmail.trim() || (needHomeTenant && !homeTenantPick)}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
{adding ? (
|
||||
|
||||
Reference in New Issue
Block a user