Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] cdac34d7ad Bump System.Text.Json from 9.0.1 to 10.0.14
---
updated-dependencies:
- dependency-name: System.Text.Json
  dependency-version: 10.0.14
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 20:06:44 +00:00
155 changed files with 1270 additions and 9566 deletions
-5
View File
@@ -24,11 +24,6 @@ updates:
dotnet-security:
applies-to: security-updates
patterns: ['*']
# Application Insights 3 is the OpenTelemetry rewrite (no telemetry processors, another sampling
# model) and the Functions worker integration still builds on 2.x: a planned migration, not a bump.
ignore:
- dependency-name: 'Microsoft.ApplicationInsights.WorkerService'
update-types: ['version-update:semver-major']
- package-ecosystem: npm
directory: /src/Web/autopilot-monitor-web
@@ -18,9 +18,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -44,7 +44,7 @@
<Import Project="V2Isolation.targets" />
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
@@ -78,7 +78,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<!-- Plan §2.16 strict separation rule — build-time enforcement. -->
@@ -13,9 +13,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -15,9 +15,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -15,7 +15,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
@@ -181,7 +181,7 @@ public class AuthFunctionSideEffectTests
return BuildRequest(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")));
}
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal, RequestContext? requestContext = null)
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal)
{
var services = new ServiceCollection();
services.AddOptions();
@@ -190,11 +190,9 @@ public class AuthFunctionSideEffectTests
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
if (requestContext != null)
items[RequestContext.ItemsKey] = requestContext;
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.Items).Returns(
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
context.SetupGet(c => c.InstanceServices).Returns(provider);
var req = new Mock<HttpRequestData>(context.Object);
@@ -1095,96 +1093,4 @@ public class AuthFunctionSideEffectTests
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(Constants.Push.DeviceStatus.Active, push.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")].Status);
}
// -------------------------------------------------------------------------
// GetCurrentUser under view-as: a Global Admin reads another tenant as its member
// -------------------------------------------------------------------------
private const string ViewedTenantId = "cccccccc-1111-2222-3333-444444444444";
/// <summary>The context PolicyEnforcementMiddleware builds for an admitted view-as request.</summary>
private static RequestContext ViewingAs(string role) => new()
{
TenantId = ViewedTenantId,
TargetTenantId = ViewedTenantId,
UserPrincipalName = Upn,
UserRole = role,
IsTenantAdmin = role == Constants.TenantRoles.Admin,
ViewAs = new ViewAsContext(TenantId, role),
};
[Fact]
public async Task GetCurrentUser_ViewAs_AnswersAsTheViewedTenantsMember_WithoutAnySignInSideEffect()
{
// The viewed tenant asks for the Terms + DPA; the caller's home row still lacks its domain — exactly the
// state in which an ordinary sign-in would seed it, onboard, and record a login.
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
viewed.DomainName = "fabrikam.example";
viewed.CompanyName = "Fabrikam";
viewed.DpaAcceptancePending = true;
var home = DefaultConfig();
home.DomainName = null!;
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((home, true));
var viewedStore = _tenantConfigMock.StubWrites(ViewedTenantId, row: viewed);
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(true);
_mcpUserMock
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
var seen = new DateTime(2026, 11, 1, 0, 0, 0, DateTimeKind.Utc);
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen(platformUtc: seen));
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Viewer));
var response = await _sut.GetCurrentUser(req, ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadBodyAsync(response);
Assert.Contains($"\"tenantId\":\"{ViewedTenantId}\"", body);
Assert.Contains("\"isGlobalAdmin\":false", body);
Assert.Contains("\"isGlobalReader\":false", body);
Assert.Contains("\"isDelegated\":false", body);
Assert.Contains("\"role\":\"Viewer\"", body);
Assert.Contains("\"isTenantAdmin\":false", body);
Assert.Contains("\"dpaAcceptancePending\":true", body);
Assert.Contains("\"whatsNewSeenPlatformUtc\":\"2026-11-01T00:00:00Z\"", body);
Assert.Contains(
$"\"viewAs\":{{\"tenantId\":\"{ViewedTenantId}\",\"tenantName\":\"Fabrikam\",\"role\":\"Viewer\",\"homeTenantId\":\"{TenantId}\"}}",
body);
// No sign-in side effect in either tenant, and no role lookup of the caller at all.
Assert.Empty(_store.Writes);
Assert.Empty(viewedStore.Writes);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_tenantAdminsMock.Verify(x => x.GetMembershipSnapshotAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_metricsRepoMock.Verify(x => x.RecordUserLoginAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_globalAdminMock.Verify(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()), Times.Never);
}
[Fact]
public async Task GetCurrentUser_ViewAs_AnInactiveTenant_ShowsItsActivationGate()
{
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
viewed.DomainName = "fabrikam.example";
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((DefaultConfig(), true));
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(false);
_mcpUserMock
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen());
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Admin));
var response = await _sut.GetCurrentUser(req, ctx);
// What the tenant's own admin gets while the tenant waits for activation — the Global Admin bypass is gone.
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Contains("PendingActivation", await ReadBodyAsync(response));
}
}
@@ -1,5 +1,4 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Functions.Services;
@@ -219,12 +218,10 @@ public class AutopilotDeviceValidatorTests
private const string Upn = "first.last@example.invalid";
private static JsonElement Device(string json) => JsonSerializer.Deserialize<JsonElement>(json);
[Fact]
public void ReadRegistration_MapsTheKeptFields_AndKeepsOnlyWhetherAUserIsAssigned()
{
var device = Device(
var device = Newtonsoft.Json.Linq.JObject.Parse(
"{\"id\":\"ap-1\",\"groupTag\":\" TEST \",\"purchaseOrderIdentifier\":\"PO-7\",\"displayName\":\"LAB-01\"," +
$"\"userPrincipalName\":\"{Upn}\",\"addressableUserName\":\"First Last\",\"productKey\":\"AAAAA-BBBBB\"}}");
@@ -244,7 +241,7 @@ public class AutopilotDeviceValidatorTests
[Fact]
public void ReadRegistration_TreatsBlankMissingAndNonTextValuesAsAbsent()
{
var device = Device(
var device = Newtonsoft.Json.Linq.JObject.Parse(
"{\"id\":\"ap-1\",\"groupTag\":\" \",\"purchaseOrderIdentifier\":null,\"displayName\":42,\"userPrincipalName\":\"\"}");
var registration = AutopilotDeviceValidator.ReadRegistration(device);
@@ -259,7 +256,7 @@ public class AutopilotDeviceValidatorTests
public void ReadRegistration_CutsOverlongText()
{
var longTag = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 50);
var device = Device($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
var device = Newtonsoft.Json.Linq.JObject.Parse($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, AutopilotDeviceValidator.ReadRegistration(device).GroupTag!.Length);
}
@@ -13,10 +13,10 @@
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
which would otherwise surface NU1903/NU1904 advisories here. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="Moq" Version="4.21.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -101,8 +101,9 @@ public class DeviceAssociationValidatorTests
Assert.Equal("preassociated", result.AssociationState);
Assert.Equal("00000000-0000-0000-0000-000000000000", result.DevicePreparationPolicyId);
Assert.Equal("admin@contoso.com", result.PreAssociatedByUserPrincipalName);
// JSON null is absence (D-420); the former Newtonsoft reader turned it into "".
Assert.Null(result.AssignedToUserPrincipalName);
// Newtonsoft.Json yields "" for JSON null when calling .ToString() on the JToken;
// downstream telemetry uses IsNullOrEmpty so both null and "" surface as "absent".
Assert.True(string.IsNullOrEmpty(result.AssignedToUserPrincipalName));
Assert.NotNull(result.PreAssociationDateTime);
Assert.Null(result.AssociationDateTime); // Graph "0001-01-01" → null (unset DateTimeOffset sentinel)
}
@@ -130,36 +130,6 @@ public class ScriptDisplayNameResolverTests
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
}
[Theory]
[InlineData("{\"id\":\"nameless\",\"displayName\":null}")]
[InlineData("{\"id\":\"nameless\",\"displayName\":42}")]
[InlineData("{\"id\":\"nameless\"}")]
public async Task PerId_script_without_a_name_is_cached_and_not_fetched_again(string body)
{
// JSON null and a non-text name read as absent (D-420); the answer is still final, so the second view
// is a cache hit instead of another Graph call.
var fix = new Fixture();
fix.GivenPermission();
fix.Repo.Meta[(TenantId, ScriptKind.Platform)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = ScriptKind.Platform,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
var r = new ScriptRef(ScriptKind.Platform, "nameless");
fix.Http.When("deviceManagementScripts/nameless", HttpStatusCode.OK, body);
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(first[r]);
Assert.Null(second[r]);
Assert.Single(fix.Http.Requests);
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
Assert.False(entry.IsNotFound);
Assert.Null(entry.DisplayName);
}
[Fact]
public async Task Empty_refs_returns_empty_dict()
{
@@ -1,658 +0,0 @@
using System;
using System.Globalization;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The contract of the Graph and Entra token-endpoint response parsers (D-420): what every realistic body,
/// and every malformed one, yields. Written against the Newtonsoft parsers before their move to
/// System.Text.Json and kept as their specification. Tests whose name ends in _D1 to _D7 pin a
/// deliberate difference of that move (listed in D-420).
/// </summary>
public class GraphResponseParsingTests
{
private const string Serial = "PF3XKQ7";
private const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
// ── GraphTokenService.ReadTokenResponse ─────────────────────────────────────
[Theory]
[InlineData("""{"access_token":"t","expires_in":3599}""", 3599L)]
[InlineData("""{"access_token":"t","expires_in":"3599"}""", 3599L)]
[InlineData("""{"access_token":"t","expires_in":" 3599 "}""", 3599L)]
[InlineData("""{"access_token":"t","expires_in":"+3599"}""", 3599L)]
[InlineData("""{"access_token":"t",/*c*/"expires_in":3599,}""", 3599L)]
[InlineData("""{"access_token":"t"}""", null)]
[InlineData("""{"access_token":"t","expires_in":null}""", null)]
public void Token_response_reads_the_token_and_its_lifetime(string body, long? expiresIn)
{
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
Assert.Equal("t", token);
Assert.Equal(expiresIn, lifetime);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("{}")]
[InlineData("""{"access_token":""}""")]
[InlineData("""{"access_token":null,"expires_in":"abc"}""")]
public void Token_response_without_a_token_yields_none_and_ignores_the_lifetime(string body)
{
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
Assert.True(string.IsNullOrEmpty(token));
Assert.Null(lifetime);
}
[Theory]
[InlineData("<html>busy</html>")]
[InlineData("[]")]
[InlineData("42")]
[InlineData("""{"access_token":"t"} x""")]
public void Token_response_that_is_not_one_object_throws(string body)
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse(body));
// D5: an expires_in that is not a whole number counts as absent; the token stays usable with the
// default lifetime. Newtonsoft threw on the first five and coerced the last two (3600, 1).
[Theory]
[InlineData("\"abc\"")]
[InlineData("\"\"")]
[InlineData("{}")]
[InlineData("[]")]
[InlineData("99999999999999999999")]
[InlineData("3599.5")]
[InlineData("true")]
public void Token_response_with_an_unusable_lifetime_keeps_the_token_D5(string expiresIn)
{
var (token, lifetime) = GraphTokenService.ReadTokenResponse($$"""{"access_token":"t","expires_in":{{expiresIn}}}""");
Assert.Equal("t", token);
Assert.Null(lifetime);
}
// ── EntraTokenRejection.Parse ───────────────────────────────────────────────
[Theory]
[InlineData("""{"error_codes":[7000229],"error_description":"AADSTS7000229: missing"}""", "AADSTS7000229")]
[InlineData("""{"error_codes":["7000229",53003]}""", "AADSTS53003")]
[InlineData("""{"error_codes":[7000229.0],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
[InlineData("""{"error_codes":7000229,"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
[InlineData("""{"error_codes":[],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
[InlineData("""{"error_codes":[1],"error_codes":[7000229]}""", "AADSTS7000229")]
[InlineData("""{"error_codes":[7000229,],/*c*/}""", "AADSTS7000229")]
public void Entra_rejection_reads_the_first_integer_code_then_the_description(string body, string code)
=> Assert.Equal(code, EntraTokenRejection.Parse(400, body).ErrorCode);
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("\"AADSTS7000229\"")]
[InlineData("42")]
[InlineData("[1,2]")]
[InlineData("<html>busy</html>")]
[InlineData("""{"error_description":null}""")]
[InlineData("""{"error_description":42}""")]
[InlineData("""{"error_codes":[7000229]} x""")]
public void Entra_rejection_without_a_readable_object_keeps_only_the_status(string? body)
{
var rejection = EntraTokenRejection.Parse(400, body);
Assert.Equal(400, rejection.StatusCode);
Assert.Null(rejection.ErrorCode);
Assert.Null(rejection.Description);
}
// D3: a description is kept as sent, even when it looks like a date (Newtonsoft dropped it).
[Fact]
public void Entra_rejection_keeps_a_date_shaped_description_D3()
=> Assert.Equal("2026-11-01T08:00:00Z", EntraTokenRejection.Parse(400, """{"error_description":"2026-11-01T08:00:00Z"}""").Description);
// D6: an error code beyond Int64 is skipped like any other unusable entry (Newtonsoft threw).
[Fact]
public void Entra_rejection_skips_an_out_of_range_code_D6()
=> Assert.Equal("AADSTS53003", EntraTokenRejection.Parse(400, """{"error_codes":[99999999999999999999,53003]}""").ErrorCode);
// ── AutopilotDeviceValidator.FindRegistration ───────────────────────────────
[Theory]
[InlineData("")]
[InlineData("null")]
[InlineData("{}")]
[InlineData("""{"value":"oops"}""")]
[InlineData("""{"value":[]}""")]
[InlineData("""{"value":[{"serialNumber":null}]}""")]
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
public void Autopilot_list_without_the_serial_is_not_registered(string body)
=> Assert.Null(AutopilotDeviceValidator.FindRegistration(body, Serial));
[Theory]
[InlineData("not json")]
[InlineData("[]")]
[InlineData("""{"value":[]} x""")]
public void Autopilot_list_that_is_not_one_object_throws(string body)
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(body, Serial));
[Fact]
public void Autopilot_list_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => AutopilotDeviceValidator.FindRegistration(
"""{"value":[null,{"id":"ap-1","serialNumber":"PF3XKQ7"}]}""", Serial));
[Fact]
public void Autopilot_list_matches_the_trimmed_serial_case_insensitively_and_stops_there()
{
var registration = AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":" pf3xkq7 ","groupTag":" Sales ","purchaseOrderIdentifier":"PO-7","displayName":"KIOSK-1","userPrincipalName":"u@contoso.com"},null]}""",
Serial);
Assert.NotNull(registration);
Assert.Equal("ap-1", registration!.DeviceId);
Assert.Equal("Sales", registration.GroupTag);
Assert.Equal("PO-7", registration.PurchaseOrderId);
Assert.Equal("KIOSK-1", registration.DisplayName);
Assert.True(registration.UserAssigned);
}
[Fact]
public void Autopilot_registration_drops_blank_and_non_text_values_and_cuts_long_text()
{
var longText = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 10);
var registration = AutopilotDeviceValidator.FindRegistration(
$$"""{"value":[{"id":" ","serialNumber":"PF3XKQ7","groupTag":true,"purchaseOrderIdentifier":{},"displayName":"{{longText}}","userPrincipalName":null}]}""",
Serial);
Assert.NotNull(registration);
Assert.Null(registration!.DeviceId);
Assert.Null(registration.GroupTag);
Assert.Null(registration.PurchaseOrderId);
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, registration.DisplayName!.Length);
Assert.False(registration.UserAssigned);
}
// D4: a serial that is not text never matches (Newtonsoft matched through its text).
[Fact]
public void Autopilot_list_with_a_numeric_serial_does_not_match_D4()
=> Assert.Null(AutopilotDeviceValidator.FindRegistration("""{"value":[{"id":"ap-1","serialNumber":12345}]}""", "12345"));
// D3: registration text is kept as sent, even when it looks like a date (Newtonsoft dropped it).
[Fact]
public void Autopilot_registration_keeps_date_shaped_text_D3()
{
var registration = AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7","groupTag":"2026-01-01T00:00:00Z","displayName":"/Date(1713096021000)/"}]}""",
Serial);
Assert.Equal("2026-01-01T00:00:00Z", registration!.GroupTag);
Assert.Equal("/Date(1713096021000)/", registration.DisplayName);
}
// ── CorporateIdentifierValidator.ReadIdentityPage ───────────────────────────
private static readonly string CorporateId =
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
[Theory]
[InlineData("", "")]
[InlineData("null", "")]
[InlineData("{}", "")]
[InlineData("""{"value":null}""", "")]
[InlineData("""{"value":[],"@odata.nextLink":""}""", "")]
[InlineData("""{"value":[],"@odata.nextLink":null}""", "")]
[InlineData("""{"value":{},"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""", "https://graph.microsoft.com/beta/next")]
[InlineData("""{"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"1234-5678"}]}""", "")]
public void Corporate_page_without_the_identifier_yields_the_next_url(string body, string nextUrl)
=> Assert.Equal((false, nextUrl), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
[Theory]
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}],"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""")]
[InlineData("""{"value":[{"importedDeviceIdentityType":"MANUFACTURERMODELSERIAL","importedDeviceIdentifier":"MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"}]}""")]
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":null},{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""")]
public void Corporate_page_with_the_identifier_is_found(string body)
=> Assert.Equal((true, string.Empty), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
[Theory]
[InlineData("not json")]
[InlineData("[]")]
public void Corporate_page_that_is_not_one_object_throws(string body)
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
[Fact]
public void Corporate_page_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[null,{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""",
CorporateId));
[Theory]
[InlineData("5")]
[InlineData("true")]
[InlineData("{}")]
public void Corporate_page_with_a_non_text_next_link_never_yields_a_page_to_follow(string nextLink)
{
// The scan must stop as a failure: either the read throws or the url cannot be requested.
try
{
var (found, nextUrl) = CorporateIdentifierValidator.ReadIdentityPage($$"""{"value":[],"@odata.nextLink":{{nextLink}}}""", CorporateId);
Assert.False(found);
Assert.False(string.IsNullOrEmpty(nextUrl));
Assert.False(Uri.IsWellFormedUriString(nextUrl, UriKind.Absolute));
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
// Throwing ends the scan as transient, like an unusable url does.
}
}
// D4: an identifier that is not text never matches (Newtonsoft matched an array through its text).
[Fact]
public void Corporate_page_with_an_array_identifier_does_not_match_D4()
=> Assert.False(CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":["Microsoft Corporation,Virtual Machine,1234-5678"]}]}""",
CorporateId).Found);
// ── DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse ─────────
[Theory]
[InlineData("")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("42")]
[InlineData("not json")]
[InlineData("{} x")]
[InlineData("""{"value":null}""")]
[InlineData("""{"value":{}}""")]
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
public void Association_list_without_the_serial_is_not_found(string body)
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void Association_list_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[null,{"serialNumber":"PF3XKQ7"}]}""", Serial));
[Fact]
public void Association_list_reads_the_matching_device_and_stops_there()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":" pf3xkq7 ","associationState":"associated","devicePreparationPolicyId":"pol-1","preassociatedByUserPrincipalName":"a@contoso.com","assignedToUserPrincipalName":"u@contoso.com","managedDeviceId":"md-1","preassociationDateTime":"2026-05-08T12:16:49Z"},null]}""",
Serial);
Assert.True(result.IsValid);
Assert.Equal("associated", result.AssociationState);
Assert.Equal("pol-1", result.DevicePreparationPolicyId);
Assert.Equal("a@contoso.com", result.PreAssociatedByUserPrincipalName);
Assert.Equal("u@contoso.com", result.AssignedToUserPrincipalName);
Assert.Equal("md-1", result.ManagedDeviceId);
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc), result.PreAssociationDateTime);
Assert.Equal(DateTimeKind.Utc, result.PreAssociationDateTime!.Value.Kind);
}
[Theory]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7"}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":null}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":""}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"0001-01-01T00:00:00Z"}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"not-a-date"}]}""")]
public void Association_date_that_is_unset_reads_as_null(string body)
=> Assert.Null(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial).AssociationDateTime);
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void Association_fields_that_are_json_null_read_as_null_D1()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"PF3XKQ7","associationState":null,"assignedToUserPrincipalName":null,"managedDeviceId":null}]}""",
Serial);
Assert.Null(result.AssociationState);
Assert.Null(result.AssignedToUserPrincipalName);
Assert.Null(result.ManagedDeviceId);
}
// D4: text whose escapes are no valid UTF-16 reads as absent, in the serial of another entry and in a field
// of the match, instead of failing the whole lookup (Newtonsoft read it into a broken string).
[Fact]
public void Association_text_with_a_broken_escape_reads_as_null_D4()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"\ud800"},{"serialNumber":"PF3XKQ7","assignedToUserPrincipalName":"u\ud800"}]}""", Serial);
Assert.True(result.IsValid);
Assert.Null(result.AssignedToUserPrincipalName);
}
// D2: the date keeps its fraction, in any culture (Newtonsoft cut it to whole seconds).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Association_date_keeps_its_fraction_in_any_culture_D2(string culture)
{
var date = InCulture(culture, () => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"2026-05-08T12:16:49.1234567Z"}]}""",
Serial).PreAssociationDateTime);
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc).AddTicks(1234567), date);
Assert.Equal(DateTimeKind.Utc, date!.Value.Kind);
}
// ── CloudPcDeviceValidator.ParseCloudPcResponse ─────────────────────────────
[Theory]
[InlineData("")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("not json")]
[InlineData("""{"value":[]} x""")]
[InlineData("""{"value":null}""")]
[InlineData("""{"value":[{"managedDeviceId":"other"}]}""")]
public void CloudPc_list_without_the_device_is_not_found(string body)
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, IntuneId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void CloudPc_list_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => CloudPcDeviceValidator.ParseCloudPcResponse(
$$"""{"value":[null,{"managedDeviceId":"{{IntuneId}}"}]}""", IntuneId));
[Fact]
public void CloudPc_list_reads_the_matching_cloud_pc()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
$$"""{"value":[{"id":"cpc-1","managedDeviceId":" {{IntuneId.ToUpperInvariant()}} ","managedDeviceName":"CPC-ADM-1","servicePlanName":"Cloud PC Enterprise"},null]}""",
IntuneId);
Assert.True(result.IsValid);
Assert.Equal("cpc-1", result.CloudPcId);
Assert.Equal("CPC-ADM-1", result.ManagedDeviceName);
Assert.Equal("Cloud PC Enterprise", result.ServicePlanName);
}
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void CloudPc_fields_that_are_json_null_read_as_null_D1()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
$$"""{"value":[{"managedDeviceId":"{{IntuneId}}","id":null,"managedDeviceName":null,"servicePlanName":null}]}""", IntuneId);
Assert.True(result.IsValid);
Assert.Null(result.CloudPcId);
Assert.Null(result.ManagedDeviceName);
Assert.Null(result.ServicePlanName);
}
// ── IntuneDeviceBindingValidator.ParseManagedDeviceResponse ─────────────────
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("42")]
[InlineData("not json")]
[InlineData("""[{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"}]""")]
[InlineData("""{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"} x""")]
[InlineData("""{"id":null}""")]
[InlineData("""{"id":42}""")]
[InlineData("""{"id":"other"}""")]
public void Managed_device_that_is_not_the_requested_one_is_not_found(string body)
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, IntuneId).Outcome);
[Theory]
[InlineData("\"managed\"", IntuneDeviceBindingOutcome.Match)]
[InlineData("null", IntuneDeviceBindingOutcome.Match)]
[InlineData("\"retirePending\"", IntuneDeviceBindingOutcome.NotManaged)]
[InlineData("\"WIPEISSUED\"", IntuneDeviceBindingOutcome.NotManaged)]
[InlineData("\"deletePending\"", IntuneDeviceBindingOutcome.NotManaged)]
public void Managed_device_on_its_way_out_is_not_managed(string managementState, IntuneDeviceBindingOutcome outcome)
=> Assert.Equal(outcome, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":" {{IntuneId.ToUpperInvariant()}} ","managementState":{{managementState}}}""", IntuneId).Outcome);
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
[InlineData("null", null)]
[InlineData("\"true\"", null)]
[InlineData("1", null)]
public void Managed_device_registration_flag_is_strictly_boolean(string value, bool? expected)
=> Assert.Equal(expected, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","azureADRegistered":{{value}}}""", IntuneId).AzureAdRegistered);
[Theory]
[InlineData("null")]
[InlineData("\"\"")]
[InlineData("\" \"")]
public void Managed_device_telemetry_fields_that_are_blank_read_as_null(string value)
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","serialNumber":{{value}},"managedDeviceOwnerType":{{value}},"deviceEnrollmentType":{{value}},"enrolledDateTime":{{value}}}""",
IntuneId);
Assert.Null(result.SerialNumber);
Assert.Null(result.OwnerType);
Assert.Null(result.EnrollmentType);
Assert.Null(result.EnrolledDateTime);
}
[Fact]
public void Managed_device_reads_its_fields()
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","deviceName":"DESKTOP-1","azureADDeviceId":"aad-1","managementState":"managed","serialNumber":"PF3XKQ7","managedDeviceOwnerType":"company","deviceEnrollmentType":"windowsAzureADJoin","enrolledDateTime":"2026-05-08T12:16:49Z"}""",
IntuneId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Equal("DESKTOP-1", result.DeviceName);
Assert.Equal("aad-1", result.AzureAdDeviceId);
Assert.Equal("PF3XKQ7", result.SerialNumber);
Assert.Equal("company", result.OwnerType);
Assert.Equal("windowsAzureADJoin", result.EnrollmentType);
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero), result.EnrolledDateTime);
}
[Fact]
public void Managed_device_unset_enrollment_date_reads_as_null()
=> Assert.Null(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"0001-01-01T00:00:00Z"}""", IntuneId).EnrolledDateTime);
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void Managed_device_fields_that_are_json_null_read_as_null_D1()
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","deviceName":null,"azureADDeviceId":null,"managementState":null}""", IntuneId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Null(result.DeviceName);
Assert.Null(result.AzureAdDeviceId);
Assert.Null(result.ManagementState);
}
// D2: the enrollment date keeps its fraction and its day and month in any culture (Newtonsoft cut the
// fraction and swapped day and month on day-first cultures).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Managed_device_enrollment_date_is_the_same_in_any_culture_D2(string culture)
{
var date = InCulture(culture, () => IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"2026-05-08T12:16:49.1234567Z"}""", IntuneId).EnrolledDateTime);
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), date);
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
}
// D4: an id that is not text never matches (Newtonsoft matched through its text).
[Fact]
public void Managed_device_with_a_numeric_id_is_not_found_D4()
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceBindingValidator.ParseManagedDeviceResponse("""{"id":12345}""", "12345").Outcome);
// ── Dates in any offset (new with System.Text.Json) ─────────────────────────
// Newtonsoft turned an offset date into machine-local time and read that back as UTC, so the instant
// depended on the host's time zone; it was only right on a UTC host.
[Theory]
[InlineData("2026-05-08T14:16:49+02:00")]
[InlineData("2026-05-08T07:16:49-05:00")]
[InlineData("2026-05-08T12:16:49Z")]
[InlineData("2026-05-08T12:16:49")]
public void Dates_in_any_offset_read_as_the_same_utc_instant(string value)
{
var expected = new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc);
var association = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
$$"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"{{value}}"}]}""", Serial).PreAssociationDateTime;
var enrolled = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"{{value}}"}""", IntuneId).EnrolledDateTime;
Assert.Equal(expected, association);
Assert.Equal(DateTimeKind.Utc, association!.Value.Kind);
Assert.Equal(new DateTimeOffset(expected), enrolled);
Assert.Equal(TimeSpan.Zero, enrolled!.Value.Offset);
}
// ── ScriptDisplayNameResolver.ReadScriptPage / ReadScriptDisplayName ────────
[Fact]
public void Script_page_keeps_object_entries_with_an_id_and_the_next_url()
{
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(
"""{"value":[1,"x",null,[],{"displayName":"no id"},{"id":null},{"id":""},{"id":"s1","displayName":"A","fileName":"a.ps1"},{"id":"s2","displayName":"B"}],"@odata.nextLink":"https://graph.microsoft.com/beta/p2"}""",
withFileName: true);
Assert.Equal(new[]
{
new ScriptDisplayNameResolver.ScriptListItem("s1", "A", "a.ps1"),
new ScriptDisplayNameResolver.ScriptListItem("s2", "B", null),
}, scripts);
Assert.Equal("https://graph.microsoft.com/beta/p2", next);
}
[Theory]
[InlineData("""{"value":[{"id":"s1"}]}""")]
[InlineData("""{"value":[],"@odata.nextLink":null}""")]
[InlineData("""{"value":[],"@odata.nextLink":""}""")]
[InlineData("""{"value":"oops"}""")]
[InlineData("{}")]
public void Script_page_without_a_next_link_ends_the_pull(string body)
=> Assert.True(string.IsNullOrEmpty(ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false).NextUrl));
[Fact]
public void Script_page_of_health_scripts_reads_no_file_name()
=> Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
"""{"value":[{"id":"s1","displayName":"A","fileName":"a.ps1"}]}""", withFileName: false).Scripts).FileName);
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("not json")]
public void Script_page_or_name_that_is_not_one_object_throws(string body)
{
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false));
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptDisplayName(body));
}
[Theory]
[InlineData("""{"displayName":"Install agent"}""", "Install agent")]
[InlineData("""{"id":"s1"}""", null)]
public void Script_name_reads_the_display_name(string body, string? name)
=> Assert.Equal(name, ScriptDisplayNameResolver.ReadScriptDisplayName(body));
// D1: a display name that is JSON null is absent (Newtonsoft read it as an empty string).
[Fact]
public void Script_name_that_is_json_null_reads_as_null_D1()
{
Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":null}"""));
Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
"""{"value":[{"id":"s1","displayName":null}]}""", withFileName: false).Scripts).DisplayName);
}
// D3: a display name is kept as sent in any culture, even when it looks like a date (Newtonsoft
// rewrote it in the current culture, "1/1/2026 12:00:00 AM" in en-US).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("th-TH")]
public void Script_name_shaped_like_a_date_is_kept_as_sent_D3(string culture)
=> Assert.Equal("2026-01-01T00:00:00Z", InCulture(culture, () =>
ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":"2026-01-01T00:00:00Z"}""")));
// D4: a display name that is not text is absent (Newtonsoft read a number as its text).
[Fact]
public void Script_name_that_is_a_number_reads_as_null_D4()
=> Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":42}"""));
// ── D7: a body cut off mid-object is unreadable ─────────────────────────────
// Newtonsoft's DeserializeObject read the part before the cut as if the open containers were closed, so a
// cut-off list could still match. Every parser now answers it like any other unreadable body. HttpClient
// throws on a premature end before a parser sees the body, so only a server-side cut can get here.
[Fact]
public void Token_response_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse("""{"access_token":"t","expires_in":3599"""));
[Fact]
public void Entra_rejection_cut_off_keeps_only_the_status_D7()
{
var rejection = EntraTokenRejection.Parse(400, """{"error_codes":[53003""");
Assert.Null(rejection.ErrorCode);
Assert.Null(rejection.Description);
}
[Fact]
public void Autopilot_list_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7"}""", Serial));
[Fact]
public void Corporate_page_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}""",
CorporateId));
[Fact]
public void Association_list_cut_off_is_not_found_D7()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse("""{"value":[{"serialNumber":"PF3XKQ7"}""", Serial);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void CloudPc_list_cut_off_is_not_found_D7()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse($$"""{"value":[{"managedDeviceId":"{{IntuneId}}"}""", IntuneId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void Managed_device_cut_off_is_not_found_D7()
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","azureADRegistered":true""", IntuneId).Outcome);
private static T InCulture<T>(string culture, Func<T> read)
{
var saved = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
return read();
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
}
@@ -23,7 +23,7 @@ public class GraphTokenServiceTests
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler, IMemoryCache? cache = null)
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -33,7 +33,7 @@ public class GraphTokenServiceTests
})
.Build();
var svc = NewService(config, new StubHttpClientFactory(handler), cache: cache);
var svc = NewService(config, new StubHttpClientFactory(handler));
return (svc, handler);
}
@@ -47,12 +47,11 @@ public class GraphTokenServiceTests
IConfiguration config,
System.Net.Http.IHttpClientFactory factory,
IGraphAccessHealthRecorder? accessHealth = null,
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null,
IMemoryCache? cache = null) =>
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null) =>
new GraphTokenService(
logger ?? NullLogger<GraphTokenService>.Instance,
factory,
cache ?? new MemoryCache(new MemoryCacheOptions()),
new MemoryCache(new MemoryCacheOptions()),
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
@@ -132,53 +131,6 @@ public class GraphTokenServiceTests
Assert.Equal(1, TokenPosts(handler, TenantA));
}
// The token stays cached for expires_in minus the 5-minute skew, clamped to 60 s .. 55 min. An absent
// or unusable expires_in (D5 in D-420: text, a fraction, zero or less) falls back to the 35-minute
// default, so 30 minutes; a value far beyond the cap is capped instead of overflowing.
[Theory]
[InlineData("3599", 3299)]
[InlineData("\"3599\"", 3299)]
[InlineData("7200", 3300)]
[InlineData("120", 60)]
[InlineData("null", 1800)]
[InlineData("\"abc\"", 1800)]
[InlineData("3599.5", 1800)]
[InlineData("0", 1800)]
[InlineData("-5", 1800)]
[InlineData("9223372036854775807", 3300)]
public async Task Token_cache_lifetime_follows_expires_in(string expiresIn, int seconds)
{
var cache = new RecordingMemoryCache();
var (svc, _) = Create(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, $$"""{"access_token":"tok-A","expires_in":{{expiresIn}}}"""), cache);
Assert.Equal("tok-A", (await svc.GetAccessTokenAsync(TenantA)).AccessToken);
var entry = Assert.Single(cache.Entries, e => e.Key is string key && key.StartsWith($"graph-token:{TenantA}:", StringComparison.Ordinal));
Assert.Equal(TimeSpan.FromSeconds(seconds), entry.AbsoluteExpirationRelativeToNow);
}
/// <summary>A memory cache that keeps every entry it creates, so a test can read the lifetime the service chose.</summary>
private sealed class RecordingMemoryCache : IMemoryCache
{
private readonly MemoryCache _inner = new(new MemoryCacheOptions());
public List<ICacheEntry> Entries { get; } = new();
public ICacheEntry CreateEntry(object key)
{
var entry = _inner.CreateEntry(key);
Entries.Add(entry);
return entry;
}
public bool TryGetValue(object key, out object? value) => _inner.TryGetValue(key, out value);
public void Remove(object key) => _inner.Remove(key);
public void Dispose() => _inner.Dispose();
}
[Fact]
public async Task InvalidateTenant_forces_fresh_token_on_next_call()
{
@@ -499,7 +451,7 @@ public class GraphTokenServiceTests
logger.Verify(l => l.Log(
Microsoft.Extensions.Logging.LogLevel.Warning,
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state != null && state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
Moq.It.IsAny<Exception?>(),
Moq.It.IsAny<Func<Moq.It.IsAnyType, Exception?, string>>()), Moq.Times.Once);
}
@@ -235,26 +235,6 @@ public class GrowthMetricsBuilderTests
Assert.Null(a.MedianDaysToFirstEnrollment);
}
[Fact]
public void Before_yesterday_is_aggregated_yesterdays_counters_count_as_activity_and_the_rows_end_the_day_before()
{
// Sessions yesterday after a 40-day pause; the first maintenance run after midnight has not written them yet.
var configs = new[] { Config('a', Utc(6, 1, 9)) };
var stats = new[] { Stats('a', 6, 5, Utc(10, 27, 22)) };
var rows = new[] { Active('a', 2026, 9, 17) };
var early = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, NoExclusions, yesterdayAggregated: false);
Assert.Equal("2026-10-26", early.UsageDaysThrough);
Assert.Equal(0, early.AllTenants.Dormant);
Assert.Equal(1, early.AllTenants.Active7Days);
// Aggregated, yesterday has no row: the counters moved for an old session, which is no activity.
var later = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, NoExclusions);
Assert.Equal("2026-10-27", later.UsageDaysThrough);
Assert.Equal(1, later.AllTenants.Dormant);
}
[Fact]
public void A_peak_needs_three_signups_and_twice_the_median_of_the_two_weeks_before()
{
@@ -124,38 +124,32 @@ public class IntuneDeviceBindingValidatorTests
Assert.Null(result.EnrolledDateTime);
}
// -- enrolledDateTime --
private static DateTimeOffset? EnrolledDateTime(string jsonValue)
=> IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{ "id": "{{DeviceId}}", "enrolledDateTime": {{jsonValue}} }""", DeviceId).EnrolledDateTime;
// -- ParseEnrolledDateTime --
[Fact]
public void EnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
public void ParseEnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
{
// Intune returns 0001-01-01T00:00:00Z when it has no enrollment timestamp. Treating that
// as a real date would report an object age of ~2000 years and poison the race analysis.
Assert.Null(EnrolledDateTime("\"0001-01-01T00:00:00Z\""));
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime("0001-01-01T00:00:00Z"));
}
[Theory]
[InlineData("null")]
[InlineData("\"\"")]
[InlineData("\" \"")]
[InlineData("\"not-a-date\"")]
[InlineData("20260824")]
[InlineData("true")]
public void EnrolledDateTime_UnusableValue_IsNull(string jsonValue)
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not-a-date")]
public void ParseEnrolledDateTime_UnusableInput_IsNull(string? raw)
{
Assert.Null(EnrolledDateTime(jsonValue));
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime(raw));
}
[Fact]
public void EnrolledDateTime_NormalizesToUtc()
public void ParseEnrolledDateTime_NormalizesToUtc()
{
// A device object created minutes ago is the signature of an enrollment race, so the
// instant has to be comparable regardless of the offset Graph reports it in.
var parsed = EnrolledDateTime("\"2026-08-24T17:20:00+02:00\"");
var parsed = IntuneDeviceBindingValidator.ParseEnrolledDateTime("2026-08-24T17:20:00+02:00");
Assert.NotNull(parsed);
Assert.Equal(TimeSpan.Zero, parsed!.Value.Offset);
@@ -1,261 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The Graph and Entra token-endpoint response parsers as they read with Newtonsoft before D-420, copied
/// verbatim as the reference for <see cref="UpstreamJsonDifferentialTests"/>. Temporary: removed together
/// with that test once the System.Text.Json parsers have run cleanly in production (backlog).
/// </summary>
internal static class LegacyUpstreamParsers
{
public static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody)
{
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
var accessToken = tokenJson?["access_token"]?.ToString();
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, (long?)tokenJson?["expires_in"]);
}
public static EntraTokenRejection ParseEntraRejection(int statusCode, string? body)
{
JObject? json = null;
if (!string.IsNullOrWhiteSpace(body))
{
try { json = JsonConvert.DeserializeObject<JToken>(body) as JObject; }
catch (JsonException) { json = null; }
}
var description = (json?["error_description"] as JValue)?.Value as string;
string? code = null;
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
if (first != null)
{
code = "AADSTS" + first.Value<long>();
}
else
{
var start = description?.IndexOf("AADSTS", StringComparison.Ordinal) ?? -1;
if (start >= 0)
{
var digits = new string(description!.Skip(start + "AADSTS".Length).TakeWhile(char.IsDigit).ToArray());
code = digits.Length == 0 ? null : "AADSTS" + digits;
}
}
string? shortened = null;
if (!string.IsNullOrWhiteSpace(description))
{
var line = description.Split('\n')[0].Trim();
shortened = line.Length <= 300 ? line : line.Substring(0, 300);
}
return new EntraTokenRejection(statusCode, code, shortened);
}
public static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial)
{
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
if (data?["value"] is not JArray devices || devices.Count == 0)
return null;
var exactDevice = devices.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
if (exactDevice == null)
return null;
return new AutopilotRegistration
{
DeviceId = RegistrationText(exactDevice["id"]),
GroupTag = RegistrationText(exactDevice["groupTag"]),
PurchaseOrderId = RegistrationText(exactDevice["purchaseOrderIdentifier"]),
DisplayName = RegistrationText(exactDevice["displayName"]),
UserAssigned = RegistrationText(exactDevice["userPrincipalName"]) != null,
};
}
private static string? RegistrationText(JToken? token)
{
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
if (string.IsNullOrEmpty(value))
return null;
return value!.Length > AutopilotDeviceValidator.MaxRegistrationFieldLength
? value.Substring(0, AutopilotDeviceValidator.MaxRegistrationFieldLength)
: value;
}
public static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier)
{
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
if (data?["value"] is JArray identities)
{
foreach (var identity in identities)
{
var type = identity?["importedDeviceIdentityType"]?.ToString();
var value = identity?["importedDeviceIdentifier"]?.ToString();
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
&& value != null
&& string.Equals(CorporateIdentifierValidator.NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
{
return (true, string.Empty);
}
}
}
return (false, data?["@odata.nextLink"]?.ToString() ?? string.Empty);
}
public static DeviceAssociationResult ParseAssociation(string responseBody, string normalizedSerial)
{
var notFound = new DeviceAssociationResult
{
IsValid = false,
SerialNumber = normalizedSerial,
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
};
JObject? data;
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
catch (JsonException) { return notFound; }
var devices = data?["value"] as JArray;
if (devices == null || devices.Count == 0)
return notFound;
var match = devices.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
if (match == null)
return notFound;
return new DeviceAssociationResult
{
IsValid = true,
SerialNumber = normalizedSerial,
AssociationState = match["associationState"]?.ToString(),
DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(),
PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(),
AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(),
PreAssociationDateTime = ParseAssociationDate(match["preassociationDateTime"]),
AssociationDateTime = ParseAssociationDate(match["associationDateTime"]),
ManagedDeviceId = match["managedDeviceId"]?.ToString()
};
}
private static DateTime? ParseAssociationDate(JToken? token)
{
if (token == null || token.Type == JTokenType.Null) return null;
var s = token.ToString();
if (string.IsNullOrEmpty(s)) return null;
if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
return dt.Year <= 1 ? null : dt;
return null;
}
public static CloudPcValidationResult ParseCloudPc(string responseBody, string normalizedId)
{
var notFound = new CloudPcValidationResult
{
IsValid = false,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
};
JObject? data;
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
catch (JsonException) { return notFound; }
var cloudPcs = data?["value"] as JArray;
if (cloudPcs == null || cloudPcs.Count == 0)
return notFound;
var match = cloudPcs.FirstOrDefault(c => string.Equals(
c?["managedDeviceId"]?.ToString()?.Trim(), normalizedId, StringComparison.OrdinalIgnoreCase));
if (match == null)
return notFound;
return new CloudPcValidationResult
{
IsValid = true,
IntuneDeviceId = normalizedId,
CloudPcId = match["id"]?.ToString(),
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
ServicePlanName = match["servicePlanName"]?.ToString()
};
}
private static readonly HashSet<string> LeavingManagementStates = new(StringComparer.OrdinalIgnoreCase)
{
"retirePending", "retireIssued", "wipePending", "wipeIssued", "deletePending",
};
public static IntuneDeviceBindingResult ParseManagedDevice(string responseBody, string normalizedId)
{
var notFound = new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
};
JObject? device;
try { device = JsonConvert.DeserializeObject<JObject>(responseBody); }
catch (JsonException) { return notFound; }
var returnedId = device?["id"]?.ToString()?.Trim();
if (string.IsNullOrEmpty(returnedId) || !string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
return notFound;
var managementState = device?["managementState"]?.ToString();
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
return new IntuneDeviceBindingResult
{
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = normalizedId,
DeviceName = device?["deviceName"]?.ToString(),
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
ManagementState = managementState,
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
? device["azureADRegistered"]!.Value<bool>()
: null,
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
ErrorMessage = leaving
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
: null
};
}
private static DateTimeOffset? ParseEnrolledDateTime(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (!DateTimeOffset.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var parsed))
return null;
return parsed.Year <= 1 ? null : parsed;
}
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
public static (List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) ReadScriptPage(string body, bool withFileName)
{
var json = JObject.Parse(body);
var values = json["value"] as JArray ?? new JArray();
var scripts = new List<ScriptDisplayNameResolver.ScriptListItem>();
foreach (var item in values.OfType<JObject>())
{
var id = item["id"]?.ToString();
if (string.IsNullOrEmpty(id)) continue;
scripts.Add(new ScriptDisplayNameResolver.ScriptListItem(id!, item["displayName"]?.ToString(), withFileName ? item["fileName"]?.ToString() : null));
}
return (scripts, json["@odata.nextLink"]?.ToString());
}
public static string? ReadScriptDisplayName(string body) => JObject.Parse(body)["displayName"]?.ToString();
}
@@ -45,24 +45,6 @@ public class LiveStreamPrincipalTests
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.NegotiatedUserIds);
}
[Fact]
public async Task Negotiate_under_view_as_binds_the_sign_in_tenant_not_the_viewed_one()
{
// Removing a Global Admin cuts the connections bound to their sign-in tenant; a view-as tab must be among them.
var signalR = new FakeSignalRNotificationService();
var req = BuildRequest(new RequestContext
{
TenantId = OtherTenantId,
UserPrincipalName = OperatorUpn,
ViewAs = new ViewAsContext(TenantId, AutopilotMonitor.Shared.Constants.TenantRoles.Admin),
});
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, OperatorUpn) }, signalR.NegotiatedUserIds);
}
[Fact]
public async Task Negotiate_without_a_principal_key_binds_no_user_id()
{
@@ -1,9 +1,6 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using static AutopilotMonitor.Functions.Services.McpUsageOverviewService;
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
@@ -105,11 +102,9 @@ public class McpUsageOverviewAssemblyTests
[TenantA] = new("community", 100, 250),
[TenantB] = new("pro", 1500, 15000),
};
// TenantB has no readable configuration: no name.
var names = new Dictionary<string, string> { [TenantA] = "contoso.com" };
var minute = new Dictionary<(string Tenant, string Principal), int> { [(TenantA, Alice)] = 120, [(TenantB, Bob)] = 150 };
return Assemble(new OverviewInputs(ThreeDays, includePlatformAdmins, Counters, facts, limits, names, LatestRoles(result.Calls), minute, result));
return Assemble(new OverviewInputs(ThreeDays, includePlatformAdmins, Counters, facts, limits, LatestRoles(result.Calls), minute, result));
}
[Fact]
@@ -230,62 +225,6 @@ public class McpUsageOverviewAssemblyTests
Assert.Equal(new[] { Bob, Alice }, minute.Select(m => m.Principal)); // 120/150 before 40/120 and 12/60
Assert.Equal((120L, 150, 2L), (minute[0].PeakRequests, minute[0].BackendLimit, minute[0].BackendRefused));
Assert.Equal((TenantA, 12L, 7L, 0L), (minute[1].TenantId, minute[1].PeakCalls, minute[1].McpRefused, minute[1].BackendRefused));
// The share the list ranks by, in whole percent: the higher of calls/MCP limit and requests/backend limit.
Assert.Equal(new[] { 80, 33 }, minute.Select(m => m.SharePercent)); // 120/150; max(12/60, 40/120)
}
[Fact]
public void Totals_SumTheLists_OnceForEveryConsumer()
{
var totals = Overview().Totals;
Assert.Equal(18, totals.Calls); // every tool of the ranking, search_docs' 4 MCP-only calls included
Assert.Equal(4, totals.McpOnlyCalls);
Assert.Equal(7, totals.Tools);
Assert.Equal(62, totals.Requests); // tenant A 55, tenant B 7 — the operator's 100 left out
Assert.Equal(2, totals.Tenants);
Assert.Equal(4, totals.Accounts); // alice, carol, the app, bob
Assert.Equal(1, totals.ServicePrincipals);
Assert.Equal(1, totals.QuotaHitAccounts); // alice's daily window
Assert.Equal(2, totals.MinuteRefusedAccounts); // alice at the MCP server, bob at the backend
Assert.Equal(2, totals.LimitAccounts);
}
[Fact]
public void Totals_WithoutTheirStore_AreAbsent_NotAPartialSum()
{
var totals = Overview(telemetry: false).Totals;
Assert.Null(totals.Calls);
Assert.Null(totals.McpOnlyCalls);
Assert.Equal(0, totals.Tools);
Assert.Equal(62, totals.Requests); // the counters still answer
Assert.Equal(3, totals.Accounts);
}
[Fact]
public void Tenants_CarryTheirDomain_WhenTheConfigurationNamesOne()
{
var tenants = Overview().Tenants;
Assert.Equal("contoso.com", tenants.Single(t => t.TenantId == TenantA).DomainName);
Assert.Null(tenants.Single(t => t.TenantId == TenantB).DomainName);
}
[Fact]
public async Task AnUnreadableTenantConfiguration_LeavesOnlyTheNameOut()
{
var config = new Mock<TenantConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
{ CallBase = false };
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantA)).ThrowsAsync(new InvalidOperationException("storage down"));
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantB)).ReturnsAsync(new TenantConfiguration { TenantId = TenantB, DomainName = "fabrikam.com" });
// Only the configuration read is exercised; the other collaborators stay unused.
var service = new McpUsageOverviewService(
null!, null!, null!, null!, config.Object, null!, null!, null!, NullLogger<McpUsageOverviewService>.Instance, TimeProvider.System);
Assert.Null(await service.ReadTenantConfigurationAsync(TenantA));
Assert.Equal("fabrikam.com", (await service.ReadTenantConfigurationAsync(TenantB))?.DomainName);
}
[Theory]
@@ -108,10 +108,16 @@ public class MetricsFilterInjectionTests
}
[Fact]
public void BuildUserUsageFilter_BothDates_NormalizeTheShapeAndBoundTheRange()
public void BuildTenantUsageFilter_TenantInjection_StaysInsideLiteral()
{
var filter = TableUserUsageRepository.BuildUserUsageFilter("user1", "2026-11-01", "2026-11-05");
var filter = TableUserUsageRepository.BuildTenantUsageFilter("' or TenantId ne '", null, null);
Assert.Equal("PartitionKey eq 'user1' and Date ge '20261101' and Date le '20261105'", filter);
Assert.Equal("TenantId eq ''' or TenantId ne '''", filter);
}
[Fact]
public void BuildTenantUsageFilter_NoScope_ReturnsNull()
{
Assert.Null(TableUserUsageRepository.BuildTenantUsageFilter(null, null, null));
}
}
@@ -548,6 +548,64 @@ public class MetricsWireParityTests
new GetMcpUserUsageResponse { UserId = userId, Records = records });
}
// ---- GetGlobalMcpUsage ---------------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageResponse_matches_the_tenant_usage_shape()
{
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var records = SampleUsageRecords("9cc42e33-0004-4b7c-a1d2-52f3aaaa0010");
AssertParity(
new { tenantId, records },
new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
}
[Fact]
public void GetGlobalMcpUsageResponse_omits_a_null_tenantId()
{
string? tenantId = null;
var records = new List<UserUsageRecord>();
AssertParity(
new { tenantId, records },
new GetGlobalMcpUsageResponse { TenantId = null, Records = records });
}
// ---- GetGlobalMcpUsageDaily ----------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageDailyResponse_matches_the_daily_summary_shape()
{
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var summaries = new List<UserUsageDailySummary>
{
new UserUsageDailySummary
{
Date = "20260830",
TenantId = tenantId,
TotalRequests = 321,
UniqueUsers = 4,
UniqueEndpoints = 11,
},
};
AssertParity(
new { tenantId, summaries },
new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
}
[Fact]
public void GetGlobalMcpUsageDailyResponse_omits_a_null_tenantId()
{
string? tenantId = null;
var summaries = new List<UserUsageDailySummary>();
AssertParity(
new { tenantId, summaries },
new GetGlobalMcpUsageDailyResponse { TenantId = null, Summaries = summaries });
}
// ---- MetricsSummary / MetricsSummaryGlobal -------------------------------------------
[Fact]
@@ -792,18 +850,12 @@ public class MetricsWireParityTests
dateTo = "20261105",
includesPlatformAdmins = false,
telemetry = new { requestsAvailable = true, mcpLogAvailable = true, mcpLogFrom = "20261104", partial = true },
totals = new
{
calls = 10L, mcpOnlyCalls = 4L, tools = 2, requests = 50L, tenants = 1, accounts = 1, servicePrincipals = 0,
quotaHitAccounts = 1, minuteRefusedAccounts = 1, limitAccounts = 1,
},
daily = new[] { new { date = "20261105", calls = 10L, requests = 35L, accounts = 3, callsByPlan = new[] { new { plan = "community", calls = 10L } } } },
tenants = new[]
{
new
{
tenantId,
domainName = "contoso.com",
quota,
calls = 7L,
requests = 50L,
@@ -860,7 +912,7 @@ public class MetricsWireParityTests
{
new
{
tenantId, principal = "admin@contoso.com", sharePercent = 33,
tenantId, principal = "admin@contoso.com",
peakCalls = 12L, peakCallsAt = at, mcpRefused = 7L, mcpLastRefusedAt = at,
peakRequests = 40L, peakRequestsAt = at, backendLimit = 120, backendRefused = 2L, backendLastRefusedAt = at,
},
@@ -873,11 +925,6 @@ public class MetricsWireParityTests
DateTo = "20261105",
IncludesPlatformAdmins = false,
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = true, McpLogAvailable = true, McpLogFrom = "20261104", Partial = true },
Totals = new McpUsageTotalsNode
{
Calls = 10, McpOnlyCalls = 4, Tools = 2, Requests = 50, Tenants = 1, Accounts = 1, ServicePrincipals = 0,
QuotaHitAccounts = 1, MinuteRefusedAccounts = 1, LimitAccounts = 1,
},
Daily = new List<McpUsageDayItem>
{
new() { Date = "20261105", Calls = 10, Requests = 35, Accounts = 3, CallsByPlan = new List<McpUsagePlanCallsItem> { new() { Plan = "community", Calls = 10 } } },
@@ -887,7 +934,6 @@ public class MetricsWireParityTests
new()
{
TenantId = tenantId,
DomainName = "contoso.com",
Quota = quota,
Calls = 7,
Requests = 50,
@@ -944,7 +990,7 @@ public class MetricsWireParityTests
{
new()
{
TenantId = tenantId, Principal = "admin@contoso.com", SharePercent = 33,
TenantId = tenantId, Principal = "admin@contoso.com",
PeakCalls = 12, PeakCallsAt = at, McpRefused = 7, McpLastRefusedAt = at,
PeakRequests = 40, PeakRequestsAt = at, BackendLimit = 120, BackendRefused = 2, BackendLastRefusedAt = at,
},
@@ -968,7 +1014,6 @@ public class MetricsWireParityTests
dateTo = "20261105",
includesPlatformAdmins = true,
telemetry = new { requestsAvailable = false, mcpLogAvailable = false },
totals = new { tools = 1, requests = 0L, tenants = 1, accounts = 1, servicePrincipals = 1, quotaHitAccounts = 0, minuteRefusedAccounts = 0, limitAccounts = 0 },
daily = Array.Empty<object>(),
tenants = new[]
{
@@ -995,7 +1040,6 @@ public class MetricsWireParityTests
DateTo = "20261105",
IncludesPlatformAdmins = true,
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = false, McpLogAvailable = false },
Totals = new McpUsageTotalsNode { Tools = 1, Requests = 0, Tenants = 1, Accounts = 1, ServicePrincipals = 1 },
Daily = new List<McpUsageDayItem>(),
Tenants = new List<McpUsageTenantItem>
{
@@ -1148,177 +1192,6 @@ public class MetricsWireParityTests
AssertParity(GrowthLiteral(null, null), GrowthResponse(null, null));
}
// ---------- GET global/metrics/tenant-activity ----------
private const string ActivityTenantId = "8bb31d22-0004-4c8d-b2e3-63f4bbbb0001";
private static readonly DateTime ActivityOnboardedAt = new(2026, 6, 30, 9, 0, 0, DateTimeKind.Utc);
private static readonly DateTime ActivityCounterAt = new(2026, 11, 6, 7, 30, 0, DateTimeKind.Utc);
private static readonly DateTime ActivityVisitAt = new(2026, 11, 6, 8, 15, 0, DateTimeKind.Utc);
private static GetTenantActivityResponse TenantActivityResponse(bool full) => new()
{
WindowDays = 30,
WindowStart = "2026-10-07",
UsageDaysThrough = "2026-11-05",
PreviousWindowStart = "2026-09-07",
RowsFrom = "2026-05-10",
PortalActivityAvailable = full,
Totals = new TenantActivityTotals
{
SignedUpTenants = 2,
ActiveTenants = 1,
ActiveTenantsPrevious = 1,
ActiveTenantsTrendPercent = full ? 0 : null,
Enrollments = 6,
EnrollmentsPrevious = 4,
EnrollmentsTrendPercent = full ? 50 : null,
Succeeded = 5,
Failed = 1,
SuccessPercent = full ? 83 : null,
Top10SharePercent = full ? 100 : null,
Stages = new TenantActivityStageCounts { NeverEnrolled = 1, Active = 1 },
PortalUsers = full ? 3 : null,
PortalTenants = full ? 1 : null,
},
Daily = new List<TenantActivityDailyItem>
{
new() { Day = "2026-11-05", Enrollments = 6, Succeeded = 5, Failed = 1, ActiveTenants = 1 },
},
Tenants = new List<TenantActivityTenantItem>
{
new()
{
TenantId = ActivityTenantId,
DomainName = "contoso.example",
CompanyName = full ? "Contoso" : null,
Edition = TenantEditionNames.ProTrial,
Stage = TenantActivityStages.Active,
OnboardedAt = full ? ActivityOnboardedAt : null,
Enrollments = 6,
EnrollmentsPrevious = 4,
TrendPercent = full ? 50 : null,
Succeeded = 5,
Failed = 1,
SuccessPercent = full ? 83 : null,
ActiveDays = 1,
DailyEnrollments = full ? new[] { 6 } : null,
FirstEnrollmentDay = full ? "2026-07-01" : null,
LastActiveDay = full ? "2026-11-05" : null,
LastCounterUpdateAt = full ? ActivityCounterAt : null,
LifetimeEnrollments = 120,
LifetimeSucceeded = 100,
LifetimeEvents = 50_000,
PortalUsers = full ? 3 : null,
LastPortalVisitAt = full ? ActivityVisitAt : null,
},
},
};
[Fact]
public void GetTenantActivityResponse_matches_the_shape_with_every_optional_key()
{
AssertParity(new
{
windowDays = 30,
windowStart = "2026-10-07",
usageDaysThrough = "2026-11-05",
previousWindowStart = "2026-09-07",
rowsFrom = "2026-05-10",
portalActivityAvailable = true,
totals = new
{
signedUpTenants = 2,
activeTenants = 1,
activeTenantsPrevious = 1,
activeTenantsTrendPercent = 0,
enrollments = 6,
enrollmentsPrevious = 4,
enrollmentsTrendPercent = 50,
succeeded = 5,
failed = 1,
successPercent = 83,
top10SharePercent = 100,
stages = new { neverEnrolled = 1, dormant = 0, @new = 0, declining = 0, active = 1, quiet = 0 },
portalUsers = 3,
portalTenants = 1,
},
daily = new[] { new { day = "2026-11-05", enrollments = 6, succeeded = 5, failed = 1, activeTenants = 1 } },
tenants = new[]
{
new
{
tenantId = ActivityTenantId,
domainName = "contoso.example",
companyName = "Contoso",
edition = "pro-trial",
stage = "active",
onboardedAt = ActivityOnboardedAt,
enrollments = 6,
enrollmentsPrevious = 4,
trendPercent = 50,
succeeded = 5,
failed = 1,
successPercent = 83,
activeDays = 1,
dailyEnrollments = new[] { 6 },
firstEnrollmentDay = "2026-07-01",
lastActiveDay = "2026-11-05",
lastCounterUpdateAt = ActivityCounterAt,
lifetimeEnrollments = 120L,
lifetimeSucceeded = 100L,
lifetimeEvents = 50_000L,
portalUsers = 3,
lastPortalVisitAt = ActivityVisitAt,
},
},
}, TenantActivityResponse(full: true));
}
[Fact]
public void GetTenantActivityResponse_omits_every_absent_figure()
{
// No sign-in history, no previous window, no finished session, no names beyond the domain.
AssertParity(new
{
windowDays = 30,
windowStart = "2026-10-07",
usageDaysThrough = "2026-11-05",
previousWindowStart = "2026-09-07",
rowsFrom = "2026-05-10",
portalActivityAvailable = false,
totals = new
{
signedUpTenants = 2,
activeTenants = 1,
activeTenantsPrevious = 1,
enrollments = 6,
enrollmentsPrevious = 4,
succeeded = 5,
failed = 1,
stages = new { neverEnrolled = 1, dormant = 0, @new = 0, declining = 0, active = 1, quiet = 0 },
},
daily = new[] { new { day = "2026-11-05", enrollments = 6, succeeded = 5, failed = 1, activeTenants = 1 } },
tenants = new[]
{
new
{
tenantId = ActivityTenantId,
domainName = "contoso.example",
edition = "pro-trial",
stage = "active",
enrollments = 6,
enrollmentsPrevious = 4,
succeeded = 5,
failed = 1,
activeDays = 1,
lifetimeEnrollments = 120L,
lifetimeSucceeded = 100L,
lifetimeEvents = 50_000L,
},
},
}, TenantActivityResponse(full: false));
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -1,271 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Contains Newtonsoft.Json (D-420): new code reads and writes JSON with System.Text.Json. Every shipped
/// source file under <c>src/</c> that uses Newtonsoft today is on a frozen baseline that may only shrink,
/// and only the projects in <see cref="ProjectsWithPackage"/> may reference the package. Test projects are
/// out of scope: they exercise Newtonsoft-bound code and never ship.
/// A file uses Newtonsoft when a code line names the <c>Newtonsoft.Json</c> namespace (using, alias,
/// fully qualified name, or the DLL name); comment lines do not count.
/// </summary>
public class NewtonsoftUsageGuardTests
{
private const string AgentWireAndState = "agent (net48): wire format and on-device state, which move only with an agent release";
private const string DecisionCore = "DecisionCore serialization shared by the agent and its tests";
private const string SummaryDialog = "summary dialog (net48): reads the agent's final status and checks the DLL ships beside it";
private const string Ingest = "agent ingest wire (D-200)";
private const string ConfigRules = "Newtonsoft-bound configuration and rule documents (PopulateObject)";
private const string QueueEnvelope = "queue envelope contract shared by producers and consumers";
private const string StoredJson = "stored JSON (table columns, blobs, report files) that existing rows must keep reading";
private const string OutgoingPayload = "outgoing notification payloads";
private const string RuleTemplates = "rule template interpolation over Newtonsoft tokens";
/// <summary>Path relative to <c>src/</c> → why it may still use Newtonsoft. Only shrinks.</summary>
private static readonly Dictionary<string, string> Baseline = new(StringComparer.OrdinalIgnoreCase)
{
["Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BuiltInConfigCatalog.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashDumpCapture.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashRecord.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/PendingCrashReporter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EventRecordWatermark.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/MdmRebootPolicyTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/BandwidthStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OsBuildChangeHost.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Persistence/StartupEventGate.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequencePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Signals/TelemetrySignalEmitter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Transitions/TelemetryTransitionEmitter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/BackendTelemetryUploader.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemSerializer.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadCursorPersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.ImeMatchingMode.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs"] = AgentWireAndState,
["Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/StateSerializer.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/TransitionSerializer.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs"] = DecisionCore,
["Agent/AutopilotMonitor.SummaryDialog/App.xaml.cs"] = SummaryDialog,
["Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml.cs"] = SummaryDialog,
["Agent/AutopilotMonitor.SummaryDialog/Models/FinalStatus.cs"] = SummaryDialog,
["Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Ingest/EventDataNormalizer.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Ingest/TelemetryPayloadParser.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Services/Ime/ImePatternHealthService.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityInventoryLoader.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Config/PatchAdminConfigurationFunction.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Helpers/RequestBody.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/BuiltInGatherRules.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/BuiltInImeLogPatterns.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/GitHubRuleRepository.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/RulesWrapper.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/Activation/AzureQueueTenantAutoApproveEnqueuer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Analyze/AzureQueueAnalyzeOnEnrollmentEndProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Backup/Queue/AzureQueueCriticalTableBackupProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Backup/Queue/CriticalTableBackupQueueWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Deletion/AzureQueueSessionDeletionMaintenanceTriggerProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Ime/AzureQueueImeMsiArchiveProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Maintenance/AzureQueueMaintenanceTriggerProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Notifications/AzureQueueNotificationDispatchProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Offboarding/AzureQueueTenantOffboardingEnqueuer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Queueing/QueueEnvelopeReader.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Queueing/QueuePollingWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/AzureQueueVulnerabilityCorrelateProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.ServerActions.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelateHandler.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/Notifications/DiscordRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/Notifications/SlackRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/Notifications/TeamsWorkflowAdaptiveCardRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/RuleTemplateInterpolator.cs"] = RuleTemplates,
};
/// <summary>The projects that may reference the Newtonsoft.Json package directly.</summary>
private static readonly string[] ProjectsWithPackage =
{
"Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj",
"Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj",
"Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj",
"Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj",
"Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj",
};
private static readonly Regex NamespaceUse = new(@"\bNewtonsoft\.Json\b", RegexOptions.Compiled);
private static readonly Regex PackageReference = new(
@"<PackageReference\s+Include\s*=\s*""Newtonsoft\.Json""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
[Fact]
public void Newtonsoft_use_is_frozen_per_file()
{
var actual = ShippedSources()
.Where(s => UsesNewtonsoft(s.Text))
.Select(s => s.Relative)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var violations = new List<string>();
foreach (var file in actual.OrderBy(f => f, StringComparer.Ordinal))
{
if (!Baseline.ContainsKey(file))
violations.Add($"{file}: NEW use of Newtonsoft.Json — read and write JSON with System.Text.Json; the Newtonsoft baseline is frozen (D-420).");
}
foreach (var file in Baseline.Keys.OrderBy(f => f, StringComparer.Ordinal))
{
if (!actual.Contains(file))
violations.Add($"{file}: on the baseline but no longer uses Newtonsoft.Json (or is gone) — remove its entry (ratchet down).");
}
Assert.True(violations.Count == 0, "Newtonsoft.Json baseline violated:\n " + string.Join("\n ", violations));
}
[Fact]
public void Newtonsoft_package_only_in_known_projects()
{
var srcRoot = Path.Combine(FindRepoRoot(), "src");
var actual = SourceFiles(srcRoot, "*.csproj", includeTestProjects: true)
.Where(f => PackageReference.IsMatch(File.ReadAllText(f)))
.Select(f => Relative(srcRoot, f))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var violations = actual.Except(ProjectsWithPackage, StringComparer.OrdinalIgnoreCase)
.Select(p => $"{p}: NEW Newtonsoft.Json package reference — use System.Text.Json (D-420).")
.Concat(ProjectsWithPackage.Except(actual, StringComparer.OrdinalIgnoreCase)
.Select(p => $"{p}: listed but no longer references Newtonsoft.Json — remove it from ProjectsWithPackage (ratchet down)."))
.OrderBy(v => v, StringComparer.Ordinal)
.ToList();
Assert.True(violations.Count == 0, "Newtonsoft.Json package references violated:\n " + string.Join("\n ", violations));
}
[Theory]
[InlineData("using Newtonsoft.Json;", true)]
[InlineData("using Newtonsoft.Json.Linq;", true)]
[InlineData("using JsonConvert = Newtonsoft.Json.JsonConvert;", true)]
[InlineData(" catch (Newtonsoft.Json.JsonException) { }", true)]
[InlineData("var dll = Path.Combine(dir, \"Newtonsoft.Json.dll\");", true)]
[InlineData("var url = \"https://x\"; Newtonsoft.Json.Linq.JObject.Parse(s); // trailing", true)]
[InlineData("var x = 1; // Newtonsoft.Json is not used here", false)]
[InlineData(" /// <summary>Newtonsoft.Json defaults on both sides.</summary>", false)]
[InlineData("// Newtonsoft.Json", false)]
[InlineData(" /* Newtonsoft.Json */", false)]
[InlineData(" * Newtonsoft.Json in a block comment", false)]
[InlineData("var rule = await RequestBody.ReadNewtonsoftAsync<AnalyzeRule>(req);", false)]
public void Use_detector_sees_code_but_not_comments(string line, bool expected)
=> Assert.Equal(expected, UsesNewtonsoft(line));
private static bool UsesNewtonsoft(string text)
{
foreach (var raw in text.Split('\n'))
{
var trimmed = raw.TrimStart();
if (trimmed.StartsWith("//", StringComparison.Ordinal) || trimmed.StartsWith("/*", StringComparison.Ordinal) || trimmed.StartsWith('*'))
continue;
var line = raw;
var comment = line.IndexOf("//", StringComparison.Ordinal);
if (comment >= 0 && !line[..comment].Contains('"'))
line = line[..comment];
if (NamespaceUse.IsMatch(line))
return true;
}
return false;
}
private static IEnumerable<(string Relative, string Text)> ShippedSources()
{
var srcRoot = Path.Combine(FindRepoRoot(), "src");
return SourceFiles(srcRoot, "*.cs", includeTestProjects: false)
.Select(f => (Relative(srcRoot, f), File.ReadAllText(f)));
}
/// <summary>
/// Walks <paramref name="root"/> without entering build output or package folders: the web and MCP
/// node_modules alone would make a plain recursive enumeration take tens of seconds.
/// </summary>
private static IEnumerable<string> SourceFiles(string root, string pattern, bool includeTestProjects)
{
var pending = new Stack<string>();
pending.Push(root);
while (pending.Count > 0)
{
var dir = pending.Pop();
foreach (var file in Directory.EnumerateFiles(dir, pattern))
yield return file;
foreach (var sub in Directory.EnumerateDirectories(dir))
{
var name = Path.GetFileName(sub);
if (SkippedFolders.Contains(name) || (!includeTestProjects && name.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase)))
continue;
pending.Push(sub);
}
}
}
private static readonly HashSet<string> SkippedFolders = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", "node_modules", ".next" };
private static string Relative(string root, string path)
=> Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/');
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -27,8 +27,6 @@ public class PlatformStatsStorageTests
{
public Mock<TableClient> Table { get; } = new();
public List<string> Filters { get; } = new();
/// <summary>The projection of every query, in order; null for a query without one.</summary>
public List<string[]?> Selects { get; } = new();
public List<List<TableTransactionAction>> Submitted { get; } = new();
public Func<string, TableEntity[]> RowsFor { get; set; } = _ => Array.Empty<TableEntity>();
public Queue<Exception> SubmitFailures { get; } = new();
@@ -37,10 +35,9 @@ public class PlatformStatsStorageTests
public Harness()
{
Table.Setup(c => c.QueryAsync<TableEntity>(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns((string filter, int? _, IEnumerable<string>? select, CancellationToken _) =>
.Returns((string filter, int? _, IEnumerable<string> _, CancellationToken _) =>
{
Filters.Add(filter);
Selects.Add(select?.ToArray());
var page = Page<TableEntity>.FromValues(RowsFor(filter), null, Mock.Of<Response>());
return AsyncPageable<TableEntity>.FromPages(new[] { page });
});
@@ -57,7 +54,6 @@ public class PlatformStatsStorageTests
var service = new Mock<TableServiceClient>();
service.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns(Table.Object);
service.Setup(s => s.GetTableClient(Constants.TableNames.UsageMetrics)).Returns(Table.Object);
service.Setup(s => s.GetTableClient(Constants.TableNames.UserActivity)).Returns(Table.Object);
Sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
}
}
@@ -285,76 +281,6 @@ public class PlatformStatsStorageTests
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetTenantActivityDaysAsync(DateTime.UtcNow.Date, DateTime.UtcNow.Date));
}
[Fact]
public async Task Tenant_activity_days_project_and_carry_the_success_and_failure_counts()
{
var h = new Harness
{
RowsFor = _ => new[]
{
new TableEntity("2026-10-01", Tenant) { ["SessionsTotal"] = 5, ["SessionsSucceeded"] = 3, ["SessionsFailed"] = 1 },
new TableEntity("2026-10-02", Tenant) { ["SessionsTotal"] = 2 },
}
};
var days = await h.Sut.GetTenantActivityDaysAsync(
new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(new[] { "PartitionKey", "RowKey", "SessionsTotal", "SessionsSucceeded", "SessionsFailed" }, Assert.Single(h.Selects));
Assert.Collection(days,
d => Assert.Equal((5, 3, 1), (d.SessionsTotal, d.SessionsSucceeded, d.SessionsFailed)),
d => Assert.Equal((2, 0, 0), (d.SessionsTotal, d.SessionsSucceeded, d.SessionsFailed)));
}
// ---------- portal visits (tenant activity) ----------
private static TableEntity Login(string tenantId, string upn, string objectId, DateTime? at)
{
var row = new TableEntity(tenantId, Guid.NewGuid().ToString("N")) { ["Upn"] = upn, ["ObjectId"] = objectId };
if (at is { } loginAt) row["LoginAt"] = loginAt;
return row;
}
[Fact]
public async Task Portal_visits_are_people_since_the_cutoff_keyed_by_object_id_else_upn()
{
const string upperTenant = "AAAAAAAA-0000-4000-8000-000000000000";
var at = new DateTime(2026, 11, 5, 9, 30, 0, DateTimeKind.Utc);
var h = new Harness
{
RowsFor = _ => new[]
{
Login(upperTenant, "alice@contoso.example", "OID-ALICE", at),
Login(Tenant, "Bob@Contoso.example", "", at), // no object id: the UPN
Login(Tenant, "app:11111111-2222-3333-4444-555555555555", "sp-object-id", at), // a service principal
Login(Tenant, "", " ", at), // no identity at all
Login(Tenant, "carol@contoso.example", "oid-carol", null), // no visit time
}
};
var visits = await h.Sut.GetPortalVisitsSinceAsync(new DateTime(2026, 8, 7, 12, 0, 0, DateTimeKind.Utc));
Assert.Equal("LoginAt ge datetime'2026-08-07T12:00:00Z'", Assert.Single(h.Filters));
Assert.Equal(new[] { "PartitionKey", "Upn", "ObjectId", "LoginAt" }, Assert.Single(h.Selects));
Assert.Collection(visits,
v =>
{
Assert.Equal(upperTenant.ToLowerInvariant(), v.TenantId);
Assert.Equal("oid-alice", v.PersonKey);
Assert.Equal(at, v.At);
Assert.Equal(DateTimeKind.Utc, v.At.Kind);
},
v => Assert.Equal((Tenant, "bob@contoso.example"), (v.TenantId, v.PersonKey)));
}
[Fact]
public async Task A_failing_portal_visit_read_throws_instead_of_reporting_nobody()
{
var h = new Harness { RowsFor = _ => throw new RequestFailedException(503, "busy") };
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetPortalVisitsSinceAsync(DateTime.UtcNow.AddDays(-90)));
}
// ---------- seen device models ----------
[Fact]
@@ -28,7 +28,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// (otherwise admin SignalR group joins + admin-audience notifications regress), while a pure GlobalReader
/// stays read-only. Also covers the cross-tenant gate keying off HasGlobalScope.
/// </summary>
public partial class PolicyEnforcementMiddlewareTests
public class PolicyEnforcementMiddlewareTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
@@ -1201,17 +1201,16 @@ public partial class PolicyEnforcementMiddlewareTests
/// <summary>
/// Operator reads whose ?tenantId= is a handler-side filter for a Global Admin / Global Reader: session reports
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), MCP
/// usage (a tenant's organization budget by account, the all-tenant overview with every tenant's accounts and
/// tools) and verdict calibration (classifier diagnostics, drift alerts). None of it is the managed tenant's
/// telemetry, so a delegated (MSP) reader never reaches them.
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), a
/// tenant's MCP usage (its accounts and their tools) and verdict calibration (classifier diagnostics, drift
/// alerts). None of it is the managed tenant's telemetry, so a delegated (MSP) reader never reaches them.
/// </summary>
public static TheoryData<string> OperatorFilterReads => new()
{
"/api/global/session-reports",
"/api/global/rule-submissions",
"/api/global/metrics/mcp-usage/organization",
"/api/global/metrics/mcp-usage/overview",
"/api/global/metrics/mcp-usage",
"/api/global/metrics/mcp-usage/daily",
"/api/global/metrics/verdict-calibration",
};
@@ -1471,8 +1470,6 @@ public partial class PolicyEnforcementMiddlewareTests
[InlineData("GET", "/api/global/distress-reports", TenantScoping.None)]
// ops-events is platform-OPERATIONAL data — GA/Reader-only by intent (not a can't-bound limit), so None.
[InlineData("GET", "/api/global/ops-events", TenantScoping.None)]
// Every tenant's use side by side (names, enrollments, portal users) — an operator view, never an MSP's.
[InlineData("GET", "/api/global/metrics/tenant-activity", TenantScoping.None)]
// Operator reads with a ?tenantId= filter: OperatorFilterReads above.
public void GlobalRoute_DelegatedAccessibility_MatchesContract(string method, string path, TenantScoping expected)
{
@@ -1,237 +0,0 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// "View as tenant": a Global Admin reads one tenant's portal as its member, read-only. DecideAsync admits the
/// headers only for a Global Admin, refuses every write, and decides the rest with the SAME evaluators over an
/// emulated member of the viewed tenant — the parity test pins that the emulation answers every read route
/// exactly like a real member of that tenant.
/// </summary>
public partial class PolicyEnforcementMiddlewareTests
{
private const string GaUpn = "ga@vendor.example";
private static ViewAsTarget ViewB(string role = Constants.TenantRoles.Admin) => new(TenantB, role);
/// <summary>A Global Admin homed in TenantA (the default identity binding) and an onboarded TenantB.</summary>
private static Harness ViewAsHarness()
{
var h = BuildHarness();
h.Repo.Setup(r => r.GetGlobalRoleAsync(GaUpn)).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantB))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantB });
return h;
}
[Fact]
public async Task ViewAs_GlobalAdmin_ReadsTheViewedTenantAsItsAdmin()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.True(r.Allowed);
var ctx = r.Context!;
Assert.Equal(TenantB, ctx.TenantId);
Assert.Equal(TenantB, ctx.TargetTenantId);
Assert.False(ctx.IsGlobalAdmin);
Assert.False(ctx.HasFleetScope);
Assert.Null(ctx.AllowedTenantIds);
Assert.Equal(Constants.TenantRoles.Admin, ctx.UserRole);
Assert.True(ctx.IsTenantAdmin);
// Identity stays the real caller's: throttling, logs and the SignalR binding key off it.
Assert.Equal(GaUpn, ctx.UserPrincipalName);
Assert.Equal(new ViewAsContext(TenantA, Constants.TenantRoles.Admin), ctx.ViewAs);
Assert.Equal(TenantA, ctx.IdentityTenantId());
}
[Theory]
[InlineData("tenant admin")]
[InlineData("global reader")]
[InlineData("delegated admin of the viewed tenant")]
[InlineData("no role")]
public async Task ViewAs_IsRefusedToEveryoneButAGlobalAdmin_AndReported(string caller)
{
const string upn = "someone@contoso.com";
var h = ViewAsHarness();
switch (caller)
{
case "tenant admin": h.AsTenantAdmin(TenantA, upn); break;
case "global reader": h.Repo.Setup(r => r.GetGlobalRoleAsync(upn)).ReturnsAsync(Constants.GlobalRoles.GlobalReader); break;
case "delegated admin of the viewed tenant": h.AsDelegated(TenantB, Constants.DelegatedRoles.DelegatedAdmin); break;
}
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), viewAs: ViewB());
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, r.ErrorCode);
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r, viewAsRequested: true));
}
[Fact]
public async Task ViewAs_ThroughMcpOrAForeignClientApp_IsRefusedEvenForAGlobalAdmin()
{
var h = ViewAsHarness();
var capped = AuthedPrincipal(TenantA, GaUpn);
ClientAppBinding.MarkCapped(capped, "33333333-3333-3333-3333-333333333333");
var mcp = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), isMcpClient: true, viewAs: ViewB());
var foreign = await h.Middleware.DecideAsync("GET", "/api/sessions", null, capped, viewAs: ViewB());
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, mcp.ErrorCode);
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, foreign.ErrorCode);
}
[Fact]
public async Task ViewAs_AdmissionAnswers_ForMalformedHeaders_UnknownTenants_AndAnonymousCallers()
{
var h = ViewAsHarness();
var malformed = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewAsTarget.Invalid);
var unknown = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn),
viewAs: new ViewAsTarget("33333333-3333-3333-3333-333333333333", Constants.TenantRoles.Admin));
var anonymous = await h.Middleware.DecideAsync("GET", "/api/sessions", null, principal: null, viewAs: ViewB());
Assert.Equal((400, Constants.ApiErrorCodes.ViewAsInvalid), (malformed.StatusCode, malformed.ErrorCode));
Assert.Equal((404, Constants.ApiErrorCodes.ViewAsTenantNotFound), (unknown.StatusCode, unknown.ErrorCode));
Assert.Equal((401, Constants.ApiErrorCodes.AuthenticationRequired), (anonymous.StatusCode, anonymous.ErrorCode));
}
[Fact]
public async Task ViewAs_RefusesWrites_RunsTheReadsDeclaredOnPostRoutes_AndRefusesTheGetThatStartsAnAction()
{
var h = ViewAsHarness();
var ga = AuthedPrincipal(TenantA, GaUpn);
// A test send would reach the customer's channels.
var write = await h.Middleware.DecideAsync("POST", $"/api/config/{TenantB}/test-notification", null, ga, viewAs: ViewB());
var join = await h.Middleware.DecideAsync("POST", "/api/realtime/groups/join", null, ga, viewAs: ViewB());
var consentFlow = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}/autopilot-device-validation/consent-url", null, ga, viewAs: ViewB());
Assert.Equal((403, Constants.ApiErrorCodes.ViewAsReadOnly), (write.StatusCode, write.ErrorCode));
Assert.Equal(ViewAsTarget.ReadOnlyMessage, write.ErrorMessage);
Assert.True(join.Allowed);
Assert.Equal((403, Constants.ApiErrorCodes.ViewAsReadOnly), (consentFlow.StatusCode, consentFlow.ErrorCode));
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(write, viewAsRequested: true));
}
[Fact]
public async Task ViewAs_ThePlatformSurface_IsClosedLikeForAMember_AndTheDenialIsNotReported()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.False(r.Allowed);
Assert.Equal((403, Constants.ApiErrorCodes.InsufficientPermissions), (r.StatusCode, r.ErrorCode));
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r, viewAsRequested: true));
// The same denial without view-as headers is the assume-breach report it always was.
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task ViewAs_AnotherTenantsRoute_IsCrossTenantDenied_EvenTheGlobalAdminsHomeTenant()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.Equal((403, Constants.ApiErrorCodes.CrossTenantAccessDenied), (r.StatusCode, r.ErrorCode));
}
[Fact]
public async Task ViewAs_ASuspendedTenant_ShowsTheSuspension()
{
var h = ViewAsHarness();
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantB))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantB, Disabled = true, DisabledReason = "Suspended for review" });
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.Equal((403, Constants.ApiErrorCodes.TenantSuspended), (r.StatusCode, r.ErrorCode));
Assert.Equal("Suspended for review", r.ErrorMessage);
}
[Fact]
public async Task ViewAs_AsViewer_ReadsTheTenantButStaysOutOfTheAdminTier()
{
var h = ViewAsHarness();
var ga = AuthedPrincipal(TenantA, GaUpn);
var sessions = await h.Middleware.DecideAsync("GET", "/api/sessions", null, ga, viewAs: ViewB(Constants.TenantRoles.Viewer));
var adminTier = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}/notification-channel-health", null, ga,
viewAs: ViewB(Constants.TenantRoles.Viewer));
Assert.True(sessions.Allowed);
Assert.Equal(Constants.TenantRoles.Viewer, sessions.Context!.UserRole);
Assert.False(sessions.Context.IsTenantAdmin);
Assert.Equal((403, Constants.ApiErrorCodes.InsufficientPermissions), (adminTier.StatusCode, adminTier.ErrorCode));
}
[Fact]
public async Task ViewAs_RoutesWithoutAJwtTier_IgnoreTheHeaders()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/health", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewAsTarget.Invalid);
Assert.True(r.Allowed);
Assert.Null(r.Context!.ViewAs);
}
/// <summary>
/// The core promise: what the Global Admin sees is what the member sees. For every route that runs in view-as
/// mode and every tenant role, the emulated decision equals the decision for a real member of that role in the
/// viewed tenant — admission, status, error code, role, admin flag, scope and tenants.
/// </summary>
[Fact]
public async Task ViewAs_DecidesEveryReadRoute_ExactlyLikeARealMemberOfTheViewedTenant()
{
const string memberUpn = "member@tenant-b.example";
const string memberOid = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
var mismatches = new List<string>();
var compared = 0;
foreach (var role in new[] { Constants.TenantRoles.Admin, Constants.TenantRoles.Operator, Constants.TenantRoles.Viewer })
{
var h = ViewAsHarness();
h.Repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new TenantMember { Upn = memberUpn, TenantId = TenantB, Role = role, IsEnabled = true });
foreach (var entry in EndpointAccessPolicyCatalog.Entries)
{
if (entry.ViewAsAccess != ViewAsAccess.Read
|| entry.Policy is EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth)
continue;
var path = "/api/" + Regex.Replace(entry.RouteTemplate, @"\{([^}]+)\}",
m => m.Groups[1].Value == "tenantId" ? TenantB : "x1");
var member = await h.Middleware.DecideAsync(entry.HttpMethod, path, null, AuthedPrincipal(TenantB, memberUpn, memberOid));
var viewed = await h.Middleware.DecideAsync(entry.HttpMethod, path, null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB(role));
compared++;
var expected = Describe(member);
var actual = Describe(viewed);
if (expected != actual)
mismatches.Add($"{role} {entry.HttpMethod} {entry.RouteTemplate}: member={expected} viewAs={actual}");
}
}
Assert.Empty(mismatches);
Assert.True(compared > 300, $"only {compared} decisions compared — the catalog walk is broken");
}
private static string Describe(PolicyEnforcementMiddleware.PolicyResult r)
=> r.Allowed
? $"allow role={r.Context!.UserRole} admin={r.Context.IsTenantAdmin} scope={r.Context.HasFleetScope} tenant={r.Context.TenantId} target={r.Context.TargetTenantId}"
: $"deny {r.StatusCode} {r.ErrorCode}";
}
@@ -126,21 +126,20 @@ public class SessionDeletionMaintenanceFunctionTests
[Fact]
public async Task RunCore_emits_LongRunningSevere_when_body_runs_past_severe_threshold()
{
// Deterministic: the body blocks until BOTH watchdogs have recorded their OpsEvents. The two
// delays are independent timers, so under load the severe one can complete first; the cancel
// in the finally would then drop a warn delay whose callback had not run yet.
// Deterministic: the body blocks until the SEVERE watchdog records its OpsEvent. Warn has a
// smaller threshold, so it is guaranteed to have fired first — neither depends on a body
// sleep outlasting a fixed wall-clock threshold.
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMilliseconds(60));
harness.SetKillSwitch(false);
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(async (SessionRetentionFanoutService.FanoutResult _, DateTime _, CancellationToken ct) =>
{
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunningSevere");
});
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
// Both watchdogs fired while the body was still running.
// Both watchdogs fire: warning first (smaller threshold), then severe (which the body waited for).
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunning");
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunningSevere");
}
@@ -152,10 +152,6 @@ public sealed class SharedManifestParityTests
["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
// Evidence keys per condition source: what a {{signal.key}} token can resolve (MCP rule lint).
["analyzeRuleEvidenceKeys"] = RuleEvidenceKeys.BySource,
// Tenant activity (GET global/metrics/tenant-activity): the lifecycle stages in their test order —
// the MCP tool's stage filter enumerates them — and the edition names the portal's plan facet uses.
["tenantActivityStages"] = TenantActivityStages.All,
["tenantEditions"] = TenantEditionNames.All,
// Vocabularies of the diagnosis headline (GET sessions/{id}/diagnosis): the portal's verdict
// texts and culprit labels key on the same values the builder writes.
["diagnosis"] = new Dictionary<string, object?>
@@ -1,370 +0,0 @@
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Counting rules of the tenant activity (GA → Metrics → Tenant Activity, MCP get_tenant_activity): the window is
/// the N full UTC days through the last aggregated day, every signup has exactly one lifecycle stage, dormant and
/// never-enrolled agree with the growth figures, the totals add up from the tenant rows, and the portal figures
/// count people inside the window only.
/// </summary>
public class TenantActivityBuilderTests
{
// Friday; yesterday (2026-11-05) is aggregated unless a test says otherwise.
private static readonly DateTime Now = new(2026, 11, 6, 15, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Today = Now.Date;
private static string Tid(char c) => $"{new string(c, 8)}-0000-4000-8000-000000000000";
private static DateTime DaysAgo(int days, int hour = 0) => Today.AddDays(-days).AddHours(hour);
private static TenantConfiguration Config(char id, DateTime? onboardedAt, string domain = "contoso.example")
=> new() { TenantId = Tid(id), DomainName = domain, OnboardedAt = onboardedAt };
private static TenantStats Stats(char id, long total, DateTime lastUpdated, long succeeded = 0, long events = 0)
=> new() { TenantId = Tid(id), TotalEnrollments = total, SuccessfulEnrollments = succeeded, TotalEventsProcessed = events, LastUpdated = lastUpdated };
private static TenantActivityDay Row(char id, int daysAgo, int total, int succeeded = 0, int failed = 0)
=> new() { TenantId = Tid(id), Day = DaysAgo(daysAgo), SessionsTotal = total, SessionsSucceeded = succeeded, SessionsFailed = failed };
private static PortalVisit Visit(char id, string person, DateTime at)
=> new() { TenantId = Tid(id), PersonKey = person, At = at };
private static GetTenantActivityResponse Build(
IReadOnlyList<TenantConfiguration> configs,
IReadOnlyList<TenantStats> stats,
IReadOnlyList<TenantActivityDay> rows,
int days = 30,
IReadOnlyList<PortalVisit>? visits = null,
bool portalAvailable = true,
bool yesterdayAggregated = true)
=> TenantActivityBuilder.Build(
configs, stats, rows, portalAvailable ? visits ?? Array.Empty<PortalVisit>() : null, Now, days, yesterdayAggregated);
private static TenantActivityTenantItem Tenant(GetTenantActivityResponse response, char id)
=> Assert.Single(response.Tenants, t => t.TenantId == Tid(id));
// ---------- window ----------
[Fact]
public void Window_is_the_n_full_days_through_yesterday_and_the_previous_window_before_it()
{
var response = Build(
new[] { Config('a', DaysAgo(100)) },
new[] { Stats('a', 7, DaysAgo(1, 9)) },
new[] { Row('a', 1, 2), Row('a', 30, 1), Row('a', 31, 4) });
Assert.Equal("2026-10-07", response.WindowStart);
Assert.Equal("2026-11-05", response.UsageDaysThrough);
Assert.Equal("2026-09-07", response.PreviousWindowStart);
Assert.Equal("2026-05-10", response.RowsFrom);
var tenant = Tenant(response, 'a');
Assert.Equal(3, tenant.Enrollments);
Assert.Equal(4, tenant.EnrollmentsPrevious);
Assert.Equal(30, response.Daily.Count);
Assert.Equal(("2026-10-07", 1), (response.Daily[0].Day, response.Daily[0].Enrollments));
Assert.Equal(("2026-11-05", 2), (response.Daily[^1].Day, response.Daily[^1].Enrollments));
}
[Theory]
[InlineData(1, "2026-11-05")]
[InlineData(7, "2026-10-30")]
[InlineData(30, "2026-10-07")]
[InlineData(90, "2026-08-08")]
public void The_window_holds_exactly_n_days_ending_yesterday(int days, string start)
{
var response = Build(Array.Empty<TenantConfiguration>(), Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>(), days);
Assert.Equal(start, response.WindowStart);
Assert.Equal(days, response.Daily.Count);
Assert.Equal(start, response.Daily[0].Day);
Assert.Equal("2026-11-05", response.Daily[^1].Day);
}
[Fact]
public void Before_yesterday_is_aggregated_the_window_ends_the_day_before_and_yesterdays_counters_keep_a_tenant_active()
{
// Sessions only yesterday: the counters moved, the daily row is not written yet.
var configs = new[] { Config('b', DaysAgo(120)) };
var stats = new[] { Stats('b', 3, DaysAgo(1, 22)) };
var rows = new[] { Row('b', 40, 1) };
var early = Build(configs, stats, rows, yesterdayAggregated: false);
Assert.Equal("2026-11-04", early.UsageDaysThrough);
Assert.Equal("2026-10-06", early.WindowStart);
var tenant = Tenant(early, 'b');
Assert.Equal("2026-11-05", tenant.LastActiveDay);
Assert.Equal(TenantActivityStages.Quiet, tenant.Stage);
// Once aggregated, a day without a row had no sessions: the counter move alone keeps nobody active.
Assert.Equal(TenantActivityStages.Dormant, Tenant(Build(configs, stats, rows), 'b').Stage);
}
// ---------- stages ----------
[Fact]
public void Every_signup_has_exactly_one_stage_in_order()
{
var configs = new[]
{
Config('n', DaysAgo(10)), // never enrolled
Config('d', DaysAgo(120)), // dormant: last sessions 40 days ago
Config('w', DaysAgo(20)), // new: first sessions 10 days ago
Config('p', DaysAgo(2)), // new: first sessions today, only the counters know
Config('c', DaysAgo(150)), // declining: 10 before, 4 now
Config('a', DaysAgo(300)), // active, signed up before the rows' horizon
Config('q', DaysAgo(100)), // quiet: only today's counters moved in 30 days
Config('g', DaysAgo(5), domain: ""), // a read of an unknown id, not a signup
};
var stats = new[]
{
Stats('d', 5, DaysAgo(40, 9)),
Stats('w', 2, DaysAgo(10, 9)),
Stats('p', 1, Today.AddHours(8)),
Stats('c', 30, DaysAgo(5, 9)),
Stats('a', 100, DaysAgo(1, 9)),
Stats('q', 4, Today.AddHours(7)),
Stats('g', 9, DaysAgo(1, 9)),
};
var rows = new[]
{
Row('d', 40, 2),
Row('w', 10, 2),
Row('c', 40, 10), Row('c', 5, 4),
Row('a', 35, 6), Row('a', 3, 6),
Row('q', 50, 1),
Row('g', 2, 9),
};
var response = Build(configs, stats, rows);
Assert.Equal(TenantActivityStages.NeverEnrolled, Tenant(response, 'n').Stage);
Assert.Equal(TenantActivityStages.Dormant, Tenant(response, 'd').Stage);
Assert.Equal(TenantActivityStages.New, Tenant(response, 'w').Stage);
Assert.Equal(TenantActivityStages.New, Tenant(response, 'p').Stage);
Assert.Equal(TenantActivityStages.Declining, Tenant(response, 'c').Stage);
Assert.Equal(TenantActivityStages.Active, Tenant(response, 'a').Stage);
Assert.Equal(TenantActivityStages.Quiet, Tenant(response, 'q').Stage);
Assert.DoesNotContain(response.Tenants, t => t.TenantId == Tid('g'));
var pending = Tenant(response, 'p');
Assert.Null(pending.FirstEnrollmentDay);
Assert.Equal("2026-11-06", pending.LastActiveDay);
Assert.Equal("2026-10-27", Tenant(response, 'w').FirstEnrollmentDay);
Assert.Null(Tenant(response, 'a').FirstEnrollmentDay); // signup before the rows' first day: unknown
var stages = response.Totals.Stages;
Assert.Equal((1, 1, 2, 1, 1, 1), (stages.NeverEnrolled, stages.Dormant, stages.New, stages.Declining, stages.Active, stages.Quiet));
Assert.Equal(response.Totals.SignedUpTenants,
stages.NeverEnrolled + stages.Dormant + stages.New + stages.Declining + stages.Active + stages.Quiet);
Assert.Equal(7, response.Totals.SignedUpTenants);
}
[Fact]
public void Dormant_wins_over_declining_and_a_short_window_leaves_recent_tenants_quiet()
{
var configs = new[] { Config('c', DaysAgo(150)), Config('q', DaysAgo(150)) };
var stats = new[] { Stats('c', 20, DaysAgo(40, 9)), Stats('q', 20, DaysAgo(10, 9)) };
var rows = new[] { Row('c', 40, 10), Row('q', 120, 5), Row('q', 10, 3) };
Assert.Equal(TenantActivityStages.Dormant, Tenant(Build(configs, stats, rows), 'c').Stage);
Assert.Equal(TenantActivityStages.Quiet, Tenant(Build(configs, stats, rows, days: 7), 'q').Stage);
}
[Fact]
public void A_return_after_a_pause_is_not_new_for_a_signup_before_the_rows_horizon()
{
var response = Build(
new[] { Config('a', DaysAgo(200)) },
new[] { Stats('a', 40, DaysAgo(2, 9)) },
new[] { Row('a', 2, 3) });
var tenant = Tenant(response, 'a');
Assert.Equal(TenantActivityStages.Active, tenant.Stage);
Assert.Null(tenant.FirstEnrollmentDay);
}
[Fact]
public void Dormant_starts_at_30_days_without_activity_and_matches_the_growth_figures()
{
var configs = new[] { Config('x', DaysAgo(100)), Config('y', DaysAgo(100)), Config('n', DaysAgo(3)) };
var stats = new[] { Stats('x', 2, DaysAgo(29, 9)), Stats('y', 2, DaysAgo(30, 9)) };
var rows = new[] { Row('x', 29, 2), Row('y', 30, 2) };
var activity = Build(configs, stats, rows);
var growth = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, Array.Empty<string>());
Assert.NotEqual(TenantActivityStages.Dormant, Tenant(activity, 'x').Stage);
Assert.Equal(TenantActivityStages.Dormant, Tenant(activity, 'y').Stage);
Assert.Equal(growth.AllTenants.Dormant, activity.Totals.Stages.Dormant);
Assert.Equal(growth.AllTenants.NeverEnrolled, activity.Totals.Stages.NeverEnrolled);
Assert.Equal(1, activity.Totals.Stages.Dormant);
}
// ---------- figures ----------
[Fact]
public void Tenant_figures_and_totals_add_up_from_the_window_rows()
{
var configs = new[] { Config('a', DaysAgo(100)), Config('b', DaysAgo(100)), Config('c', DaysAgo(100)) };
var stats = new[]
{
Stats('a', 50, DaysAgo(1, 9), succeeded: 40, events: 9_000),
Stats('b', 8, DaysAgo(3, 9)),
Stats('c', 4, DaysAgo(45, 9)),
};
var rows = new[]
{
Row('a', 1, 4, succeeded: 3, failed: 1), Row('a', 2, 2, succeeded: 2),
Row('a', 40, 4),
Row('b', 3, 1),
Row('c', 45, 4),
};
var response = Build(configs, stats, rows);
var a = Tenant(response, 'a');
Assert.Equal((6, 4, 5, 1, 2), (a.Enrollments, a.EnrollmentsPrevious, a.Succeeded, a.Failed, a.ActiveDays));
Assert.Equal(50, a.TrendPercent);
Assert.Equal(83, a.SuccessPercent);
Assert.Equal((50L, 40L, 9_000L), (a.LifetimeEnrollments, a.LifetimeSucceeded, a.LifetimeEvents));
Assert.Equal(30, a.DailyEnrollments!.Length);
Assert.Equal(4, a.DailyEnrollments[^1]);
Assert.Equal(2, a.DailyEnrollments[^2]);
var b = Tenant(response, 'b');
Assert.Null(b.TrendPercent); // nothing in the previous window
Assert.Null(b.SuccessPercent); // no finished session
var c = Tenant(response, 'c');
Assert.Equal(-100, c.TrendPercent);
Assert.Null(c.DailyEnrollments);
var totals = response.Totals;
Assert.Equal((2, 2, 7, 8), (totals.ActiveTenants, totals.ActiveTenantsPrevious, totals.Enrollments, totals.EnrollmentsPrevious));
Assert.Equal(0, totals.ActiveTenantsTrendPercent);
Assert.Equal(-13, totals.EnrollmentsTrendPercent);
Assert.Equal((5, 1, (int?)83), (totals.Succeeded, totals.Failed, totals.SuccessPercent));
Assert.Equal(100, totals.Top10SharePercent);
Assert.Equal(totals.Enrollments, response.Daily.Sum(d => d.Enrollments));
Assert.Equal(1, response.Daily[^1].ActiveTenants);
Assert.Equal(2, response.Daily[^3].ActiveTenants + response.Daily[^1].ActiveTenants);
}
[Fact]
public void Tenants_come_by_window_enrollments_then_lifetime_and_the_top_10_share_needs_enrollments()
{
var configs = new[] { Config('a', DaysAgo(50)), Config('b', DaysAgo(50)), Config('c', DaysAgo(50)) };
var stats = new[] { Stats('a', 1, DaysAgo(45, 9)), Stats('b', 9, DaysAgo(45, 9)), Stats('c', 3, DaysAgo(45, 9)) };
var response = Build(configs, stats, new[] { Row('a', 45, 1), Row('b', 45, 9), Row('c', 45, 3) });
Assert.Equal(new[] { Tid('b'), Tid('c'), Tid('a') }, response.Tenants.Select(t => t.TenantId));
Assert.Null(response.Totals.Top10SharePercent);
Assert.Equal(-100, response.Totals.EnrollmentsTrendPercent);
}
[Fact]
public void Tenant_ids_join_case_insensitively()
{
var config = new TenantConfiguration { TenantId = Tid('a').ToUpperInvariant(), DomainName = "contoso.example", OnboardedAt = DaysAgo(60) };
var response = Build(new[] { config }, new[] { Stats('a', 5, DaysAgo(1, 9)) }, new[] { Row('a', 1, 5) },
visits: new[] { Visit('a', "oid-1", DaysAgo(2, 10)) });
var tenant = Assert.Single(response.Tenants);
Assert.Equal(5, tenant.Enrollments);
Assert.Equal(5, tenant.LifetimeEnrollments);
Assert.Equal(1, tenant.PortalUsers);
}
// ---------- portal ----------
[Fact]
public void Portal_users_count_people_inside_the_window_and_the_last_visit_takes_the_whole_history()
{
var visits = new[]
{
Visit('a', "oid-1", DaysAgo(30)), // the window's first day, midnight
Visit('a', "oid-1", DaysAgo(3, 8)), // the same person again
Visit('a', "oid-2", DaysAgo(31, 23)), // the day before the window
Visit('a', "oid-3", Today.AddHours(9)), // today, after the window
Visit('b', "oid-9", DaysAgo(60, 12)), // never enrolled, came back two months ago
};
var response = Build(
new[] { Config('a', DaysAgo(100)), Config('b', DaysAgo(80)) },
new[] { Stats('a', 3, DaysAgo(1, 9)) },
new[] { Row('a', 1, 3) },
visits: visits);
Assert.True(response.PortalActivityAvailable);
var a = Tenant(response, 'a');
Assert.Equal(1, a.PortalUsers);
Assert.Equal(Today.AddHours(9), a.LastPortalVisitAt);
var b = Tenant(response, 'b');
Assert.Equal(0, b.PortalUsers);
Assert.Equal(DaysAgo(60, 12), b.LastPortalVisitAt);
Assert.Equal(((int?)1, (int?)1), (response.Totals.PortalUsers, response.Totals.PortalTenants));
}
[Fact]
public void Without_the_sign_in_history_the_portal_figures_are_absent_not_zero()
{
var response = Build(new[] { Config('a', DaysAgo(100)) }, new[] { Stats('a', 3, DaysAgo(1, 9)) }, new[] { Row('a', 1, 3) },
portalAvailable: false);
Assert.False(response.PortalActivityAvailable);
Assert.Null(response.Totals.PortalUsers);
Assert.Null(response.Totals.PortalTenants);
var tenant = Assert.Single(response.Tenants);
Assert.Null(tenant.PortalUsers);
Assert.Null(tenant.LastPortalVisitAt);
Assert.Equal(3, tenant.Enrollments);
}
// ---------- identity ----------
[Fact]
public void Edition_follows_msp_then_plan_then_a_running_trial()
{
var msp = Config('m', DaysAgo(10));
msp.ManagedByProTenantId = Tid('z');
msp.PlanTier = "pro";
var pro = Config('p', DaysAgo(10));
pro.PlanTier = "pro";
var trial = Config('t', DaysAgo(10));
trial.TrialExpiresUtc = Now.AddDays(5);
var expired = Config('e', DaysAgo(10));
expired.TrialExpiresUtc = Now.AddDays(-1);
var community = Config('c', DaysAgo(10));
var response = Build(new[] { msp, pro, trial, expired, community }, Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>());
Assert.Equal(TenantEditionNames.ProMsp, Tenant(response, 'm').Edition);
Assert.Equal(TenantEditionNames.Pro, Tenant(response, 'p').Edition);
Assert.Equal(TenantEditionNames.ProTrial, Tenant(response, 't').Edition);
Assert.Equal(TenantEditionNames.Community, Tenant(response, 'e').Edition);
Assert.Equal(TenantEditionNames.Community, Tenant(response, 'c').Edition);
}
[Fact]
public void A_tenant_row_carries_its_names_signup_and_counter_time()
{
var config = Config('a', DaysAgo(30, 10), domain: "fabrikam.example");
config.CompanyName = "Fabrikam";
var blank = Config('b', DaysAgo(30, 10));
blank.CompanyName = " ";
var response = Build(new[] { config, blank }, new[] { Stats('a', 1, DaysAgo(2, 11)) }, new[] { Row('a', 2, 1) });
var a = Tenant(response, 'a');
Assert.Equal(("fabrikam.example", "Fabrikam"), (a.DomainName, a.CompanyName));
Assert.Equal(DaysAgo(30, 10), a.OnboardedAt);
Assert.Equal(DaysAgo(2, 11), a.LastCounterUpdateAt);
var b = Tenant(response, 'b');
Assert.Null(b.CompanyName);
Assert.Null(b.LastCounterUpdateAt);
}
}
@@ -1,65 +0,0 @@
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ratchet: a function takes the tenant whose data it reads from the request context — <c>TenantHelper.GetTenantId(req)</c>
/// or <c>RequestContext.TenantId</c> — never from the token. Only those follow "view as tenant", where a Global Admin
/// reads another tenant as its member; a data read off the token's <c>tid</c> would silently show the Global Admin's
/// own tenant under the viewed tenant's name. The baseline lists the reads that mean the caller's IDENTITY; it only
/// shrinks.
/// </summary>
public class TenantClaimReadGuardTests
{
private static readonly Regex TokenTenantRead = new(
@"\.GetTenantId\(\)|FindFirst\(""tid""\)|identity/claims/tenantid", RegexOptions.Compiled);
/// <summary>Token-tenant reads that identify the caller: file → why.</summary>
private static readonly IReadOnlyDictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Functions/Infrastructure/AuthFunction.cs"] =
"auth/me identifies and onboards the sign-in tenant (its view-as branch runs first), the caller's own What's-new marker and Terms + DPA",
["Functions/Infrastructure/McpUserFunction.cs"] = "the MCP front door binds the caller's identity",
["Functions/Metrics/McpUsageMetricsFunction.cs"] = "the caller's own MCP usage (me)",
["Functions/Rules/PreviewWhitelistFunction.cs"] = "the caller's own tenant's activation notification address",
};
private static string FunctionsRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
}
[Fact]
public void Functions_read_the_data_tenant_from_the_request_context_not_from_the_token()
{
var root = FunctionsRoot();
var seen = new HashSet<string>(StringComparer.Ordinal);
var offenders = new List<string>();
foreach (var sub in new[] { "Functions", "Pagination" })
{
foreach (var path in Directory.EnumerateFiles(Path.Combine(root, sub), "*.cs", SearchOption.AllDirectories))
{
if (!TokenTenantRead.IsMatch(File.ReadAllText(path)))
continue;
var rel = Path.GetRelativePath(root, path).Replace('\\', '/');
seen.Add(rel);
if (!Baseline.ContainsKey(rel))
offenders.Add(rel);
}
}
Assert.True(offenders.Count == 0,
"Token tenant read in a function — take the data tenant from TenantHelper.GetTenantId(req) or the request " +
"context, or add the file to the baseline with the identity reason:\n " + string.Join("\n ", offenders));
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
Assert.True(stale.Count == 0,
"Baseline entries without a token tenant read any more — remove them (the ratchet only shrinks):\n " +
string.Join("\n ", stale));
}
}
@@ -1,123 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using Xunit;
using static AutopilotMonitor.Functions.Tests.UpstreamParserReadings;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Differential test for the move of the Graph and Entra token-endpoint parsers to System.Text.Json (D-420):
/// <see cref="UpstreamResponseCases"/> builds response bodies from a fixed seed per parser, and the production
/// parser must yield exactly what the Newtonsoft reference in <see cref="LegacyUpstreamParsers"/> yields: same
/// result, or both throw (every caller treats any escaped exception the same way).
/// The bodies leave out the inputs of the deliberate differences D1 to D6; those are pinned in
/// <see cref="GraphResponseParsingTests"/>. A body cut off mid-object (D7) is compared with the production
/// parser's answer to an unreadable body instead: Newtonsoft's DeserializeObject read the part before the cut
/// as if the open containers were closed.
/// Temporary: removed with <see cref="LegacyUpstreamParsers"/> after a clean production run (backlog). The
/// generator and the readings stay for <see cref="UpstreamJsonInvariantTests"/>.
/// </summary>
public class UpstreamJsonDifferentialTests
{
private const int CaseCount = 20_000;
[Fact]
public void Token_response_matches_the_reference()
=> Compare(seed: 4201, g => g.TokenBody(),
b => Describe(GraphTokenService.ReadTokenResponse(b)),
b => Describe(LegacyUpstreamParsers.ReadTokenResponse(b)),
hit: o => o.StartsWith("token=\"", StringComparison.Ordinal));
[Fact]
public void Entra_rejection_matches_the_reference()
=> Compare(seed: 4202, g => g.EntraBody(),
b => Describe(EntraTokenRejection.Parse(400, b)),
b => Describe(LegacyUpstreamParsers.ParseEntraRejection(400, b)),
hit: o => o.Contains("code=\"AADSTS", StringComparison.Ordinal));
[Fact]
public void Autopilot_list_matches_the_reference()
=> Compare(seed: 4203, g => g.AutopilotBody(Serial),
b => Describe(AutopilotDeviceValidator.FindRegistration(b, Serial)),
b => Describe(LegacyUpstreamParsers.FindRegistration(b, Serial)),
hit: o => o.StartsWith("id=", StringComparison.Ordinal));
[Fact]
public void Corporate_page_matches_the_reference()
=> Compare(seed: 4204, g => g.CorporateBody(),
b => Describe(CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId)),
b => Describe(LegacyUpstreamParsers.ReadIdentityPage(b, CorporateId)),
hit: o => o.StartsWith("found=True", StringComparison.Ordinal));
[Fact]
public void Association_list_matches_the_reference()
=> Compare(seed: 4205, g => g.AssociationBody(Serial),
b => Describe(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial)),
b => Describe(LegacyUpstreamParsers.ParseAssociation(b, Serial)),
hit: o => o.StartsWith("valid=True", StringComparison.Ordinal));
[Fact]
public void CloudPc_list_matches_the_reference()
=> Compare(seed: 4206, g => g.CloudPcBody(IntuneId),
b => Describe(CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId)),
b => Describe(LegacyUpstreamParsers.ParseCloudPc(b, IntuneId)),
hit: o => o.StartsWith("valid=True", StringComparison.Ordinal));
[Fact]
public void Managed_device_matches_the_reference()
=> Compare(seed: 4207, g => g.ManagedDeviceBody(IntuneId),
b => Describe(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId)),
b => Describe(LegacyUpstreamParsers.ParseManagedDevice(b, IntuneId)),
hit: o => !o.StartsWith("outcome=NotFound", StringComparison.Ordinal) && o != "<throws>");
[Fact]
public void Script_page_matches_the_reference()
=> Compare(seed: 4208, g => g.ScriptPageBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true)),
b => Describe(LegacyUpstreamParsers.ReadScriptPage(b, withFileName: true)),
hit: o => o.StartsWith("\"", StringComparison.Ordinal));
[Fact]
public void Script_name_matches_the_reference()
=> Compare(seed: 4209, g => g.ScriptNameBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptDisplayName(b)),
b => Describe(LegacyUpstreamParsers.ReadScriptDisplayName(b)),
hit: o => o != "<null>" && o != "<throws>");
private static void Compare(int seed, Func<UpstreamResponseCases, string> body, Func<string, string> actual, Func<string, string> reference, Func<string, bool> hit)
{
// The reference formats dates in the current culture before parsing them back; en-US keeps its
// whole-second "Z" dates exact. The culture swap on other hosts is pinned in GraphResponseParsingTests.
var saved = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = new CultureInfo("en-US");
try
{
var generator = new UpstreamResponseCases(seed);
var unreadable = Outcome(() => actual("not json"));
var mismatches = new List<string>();
int hits = 0, throws = 0;
for (var i = 0; i < CaseCount && mismatches.Count < 5; i++)
{
var input = body(generator);
var got = Outcome(() => actual(input));
var want = generator.LastWasCutOff ? unreadable : Outcome(() => reference(input));
if (got != want)
mismatches.Add($"seed {seed}, case {i}: body {input}\n production: {got}\n reference: {want}");
if (hit(want)) hits++;
if (want == "<throws>") throws++;
}
Assert.True(mismatches.Count == 0, "Parser differs from the Newtonsoft reference:\n " + string.Join("\n ", mismatches));
// The generator must exercise the parser, not compare 20,000 identical misses.
Assert.True(hits >= CaseCount / 50, $"only {hits} of {CaseCount} cases reach a result worth comparing");
Assert.True(throws <= CaseCount * 7 / 10, $"{throws} of {CaseCount} cases throw; the generator barely reaches the parser");
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
}
@@ -1,340 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using Xunit;
using static AutopilotMonitor.Functions.Tests.UpstreamParserReadings;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Invariant fuzz for the Graph and Entra token-endpoint parsers (D-420), without a reference. Over bodies of
/// every value kind, the inputs of D1 to D7 included (<see cref="UpstreamResponseCases"/>), each parser throws
/// only what its callers expect, reads a body the same way however it is laid out, and returns no text the body
/// did not send; <see cref="UpstreamJson.ParseObjectOrNull"/> yields an object, null or a JsonException for any
/// text. Permanent: 500 cases per parser and check.
/// </summary>
public class UpstreamJsonInvariantTests
{
private const int CaseCount = 500;
/// <summary>An exception kind a parser's callers expect, named for the failure message.</summary>
private sealed record Thrown(string Name, Func<Exception, bool> Matches);
// The callers catch JsonException or everything; an unreadable body or next link throws it.
private static readonly Thrown Unreadable = new("JsonException", ex => ex is JsonException);
// A list entry that is no object before the match: the validators answer transient, as before the move.
// Only the exact type counts; ObjectDisposedException derives from it and would mean a disposed document.
private static readonly Thrown NonObjectEntry = new("InvalidOperationException", ex => ex.GetType() == typeof(InvalidOperationException));
/// <summary>A parser as the fuzz drives it.</summary>
/// <param name="Seed">The generator seed; each check offsets it to see other bodies.</param>
/// <param name="Body">The body generator for this parser.</param>
/// <param name="Read">The canonical reading of a body.</param>
/// <param name="Texts">Every text the result took from the body.</param>
/// <param name="MayThrow">The exception kinds that may escape; every one of them must also occur.</param>
private sealed record Parser(
int Seed,
Func<UpstreamResponseCases, string> Body,
Func<string, string> Read,
Func<string, IEnumerable<string?>> Texts,
Thrown[] MayThrow);
private static readonly Dictionary<string, Parser> Parsers = new()
{
["token"] = new(4301, g => g.TokenBody(),
b => Describe(GraphTokenService.ReadTokenResponse(b)),
b => new[] { GraphTokenService.ReadTokenResponse(b).AccessToken },
new[] { Unreadable }),
["entra"] = new(4302, g => g.EntraBody(),
b => Describe(EntraTokenRejection.Parse(400, b)),
b => new[] { EntraTokenRejection.Parse(400, b).Description },
Array.Empty<Thrown>()),
["autopilot"] = new(4303, g => g.AutopilotBody(Serial),
b => Describe(AutopilotDeviceValidator.FindRegistration(b, Serial)),
b => AutopilotDeviceValidator.FindRegistration(b, Serial) is { } r
? new[] { r.DeviceId, r.GroupTag, r.PurchaseOrderId, r.DisplayName }
: Array.Empty<string?>(),
new[] { Unreadable, NonObjectEntry }),
["corporate"] = new(4304, g => g.CorporateBody(),
b => Describe(CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId)),
// An empty next url is the parser's own "no further page", not text from the body.
b => new[] { CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId).NextUrl is { Length: > 0 } next ? next : null },
new[] { Unreadable, NonObjectEntry }),
["association"] = new(4305, g => g.AssociationBody(Serial),
b => Describe(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial)),
b =>
{
var r = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial);
return new[] { r.AssociationState, r.DevicePreparationPolicyId, r.PreAssociatedByUserPrincipalName, r.AssignedToUserPrincipalName, r.ManagedDeviceId };
},
new[] { NonObjectEntry }),
["cloudpc"] = new(4306, g => g.CloudPcBody(IntuneId),
b => Describe(CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId)),
b =>
{
var r = CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId);
return new[] { r.CloudPcId, r.ManagedDeviceName, r.ServicePlanName };
},
new[] { NonObjectEntry }),
["managed-device"] = new(4307, g => g.ManagedDeviceBody(IntuneId),
b => Describe(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId)),
b =>
{
var r = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId);
return new[] { r.DeviceName, r.AzureAdDeviceId, r.ManagementState, r.SerialNumber, r.OwnerType, r.EnrollmentType };
},
Array.Empty<Thrown>()),
["script-page"] = new(4308, g => g.ScriptPageBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true)),
b =>
{
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true);
return scripts.SelectMany(s => new[] { s.Id, s.DisplayName, s.FileName }).Append(next);
},
new[] { Unreadable }),
["script-name"] = new(4309, g => g.ScriptNameBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptDisplayName(b)),
b => new[] { ScriptDisplayNameResolver.ReadScriptDisplayName(b) },
new[] { Unreadable }),
};
public static IEnumerable<object[]> ParserNames => Parsers.Keys.Select(name => new object[] { name });
[Theory]
[MemberData(nameof(ParserNames))]
public void A_parser_throws_only_what_its_callers_expect(string name)
{
var parser = Parsers[name];
var cases = new UpstreamResponseCases(parser.Seed, everyValueKind: true);
var seen = new HashSet<string>();
for (var i = 0; i < CaseCount; i++)
{
var body = parser.Body(cases);
try
{
parser.Read(body);
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
var kind = parser.MayThrow.FirstOrDefault(t => t.Matches(ex));
Assert.True(kind != null, $"{name}, case {i}: {ex.GetType().Name} escapes ({ex.Message}) for body {body}");
seen.Add(kind!.Name);
}
}
// Every listed kind still occurs, so the list cannot quietly outgrow what the parser does.
Assert.Equal(parser.MayThrow.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal), seen.OrderBy(n => n, StringComparer.Ordinal));
}
[Theory]
[MemberData(nameof(ParserNames))]
public void Layout_never_changes_what_a_parser_reads(string name)
{
var parser = Parsers[name];
var cases = new UpstreamResponseCases(parser.Seed + 100, everyValueKind: true);
var layout = new Random(parser.Seed);
var compared = 0;
for (var i = 0; i < CaseCount; i++)
{
var body = parser.Body(cases);
if (ReadableObject(body) is not { } root)
continue;
var expected = Outcome(() => parser.Read(body));
foreach (var spread in new[] { false, true })
{
var twin = Render(root, layout, spread);
var actual = Outcome(() => parser.Read(twin));
Assert.True(expected == actual, $"{name}, case {i}: body {body}\n laid out as {twin}\n reads {actual}\n instead of {expected}");
}
compared++;
}
Assert.True(compared >= CaseCount / 2, $"only {compared} of {CaseCount} bodies were readable objects");
}
[Theory]
[MemberData(nameof(ParserNames))]
public void A_parser_returns_only_text_the_body_sent(string name)
{
var parser = Parsers[name];
var cases = new UpstreamResponseCases(parser.Seed + 200, everyValueKind: true);
var checkedTexts = 0;
for (var i = 0; i < CaseCount; i++)
{
var body = parser.Body(cases);
string?[] texts;
try
{
texts = parser.Texts(body).ToArray();
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
continue; // which exceptions may escape is the first check's business
}
var sent = ReadableObject(body) is { } root ? SentTexts(root) : new List<string>();
foreach (var text in texts.OfType<string>())
{
// Trimming and cutting keep a text inside what was sent; an empty text needs a blank one.
var fromBody = text.Length == 0
? sent.Any(string.IsNullOrWhiteSpace)
: sent.Any(s => s.Contains(text, StringComparison.Ordinal));
Assert.True(fromBody, $"{name}, case {i}: \"{text}\" is no text the body sent: {body}");
checkedTexts++;
}
}
// The corporate page returns its next url as the only text, so the bar is low.
Assert.True(checkedTexts >= CaseCount / 50, $"only {checkedTexts} texts came back from {CaseCount} bodies");
}
[Fact]
public void ParseObjectOrNull_yields_an_object_or_null_or_throws_JsonException_for_any_text()
{
var edits = new Random(4310);
int objects = 0, nulls = 0, refused = 0;
foreach (var parser in Parsers.Values)
{
var cases = new UpstreamResponseCases(parser.Seed + 300, everyValueKind: true);
for (var i = 0; i < CaseCount / 2; i++)
{
var body = parser.Body(cases);
foreach (var text in new[] { body, Mutate(body, edits) })
{
try
{
if (UpstreamJson.ParseObjectOrNull(text) is { } json)
{
Assert.Equal(JsonValueKind.Object, json.ValueKind);
objects++;
}
else
{
nulls++;
}
}
catch (JsonException)
{
refused++;
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
Assert.Fail($"{ex.GetType().Name} ({ex.Message}) for text {text}");
}
}
}
}
Assert.True(objects > 0 && nulls > 0 && refused > 0, $"objects {objects}, nulls {nulls}, refused {refused}");
}
private static JsonElement? ReadableObject(string body)
{
try
{
return UpstreamJson.ParseObjectOrNull(body);
}
catch (JsonException)
{
return null;
}
}
/// <summary>Every string value in the body, decoded; one whose escapes are no valid UTF-16 is skipped.</summary>
private static List<string> SentTexts(JsonElement root)
{
var texts = new List<string>();
Collect(root);
return texts;
void Collect(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var member in element.EnumerateObject())
Collect(member.Value);
break;
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
Collect(item);
break;
case JsonValueKind.String:
try { texts.Add(element.GetString()!); }
catch (InvalidOperationException) { }
break;
}
}
}
/// <summary>
/// The body written again in another layout: compact, or spread out with block and line comments and
/// trailing commas. Object members change their order unless a name repeats (the last one must stay last);
/// values keep their raw text, escapes and number form included.
/// </summary>
private static string Render(JsonElement root, Random rng, bool spread)
{
var text = new StringBuilder();
Write(root);
return text.ToString();
void Write(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
var members = element.EnumerateObject().ToList();
if (members.Select(m => m.Name).Distinct(StringComparer.Ordinal).Count() == members.Count)
members = members.OrderBy(_ => rng.Next()).ToList();
text.Append(spread ? "{ /* object */\n " : "{");
for (var i = 0; i < members.Count; i++)
{
if (i > 0)
text.Append(spread ? (rng.Next(2) == 0 ? ",// next\n " : " ,\t/* next */ ") : ",");
text.Append(JsonSerializer.Serialize(members[i].Name)).Append(spread ? " : " : ":");
Write(members[i].Value);
}
text.Append(spread && members.Count > 0 ? ",\n}" : "}");
break;
case JsonValueKind.Array:
var items = element.EnumerateArray().ToList();
text.Append(spread ? "[\n " : "[");
for (var i = 0; i < items.Count; i++)
{
if (i > 0)
text.Append(spread ? " , " : ",");
Write(items[i]);
}
text.Append(spread && items.Count > 0 ? ",\n]" : "]");
break;
default:
text.Append(element.GetRawText());
break;
}
}
}
/// <summary>One random edit: a character removed, inserted or replaced, or the text cut short.</summary>
private static string Mutate(string text, Random rng)
{
if (text.Length == 0)
return "{";
const string alphabet = "{}[]\",:/*\\ \n0123456789-+.eEtrufalsné";
var at = rng.Next(text.Length);
var c = alphabet[rng.Next(alphabet.Length)].ToString();
return rng.Next(4) switch
{
0 => text.Remove(at, 1),
1 => text.Insert(at, c),
2 => text.Substring(0, at),
_ => text.Remove(at, 1).Insert(at, c),
};
}
}
@@ -1,298 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The contract of <see cref="UpstreamJson"/> (D-420), the reader under every Graph and Entra token-endpoint
/// parser: one test group per member, then the culture matrix. The parser-level contract is in
/// <see cref="GraphResponseParsingTests"/>.
/// </summary>
public class UpstreamJsonTests
{
private static JsonElement Obj(string json) => UpstreamJson.ParseObjectOrNull(json)!.Value;
// ── ParseObjectOrNull ───────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\r\n\t")]
[InlineData("null")]
[InlineData(" null ")]
[InlineData("/*c*/ null")]
public void ParseObjectOrNull_reads_a_blank_body_and_a_null_root_as_no_object(string? body)
=> Assert.Null(UpstreamJson.ParseObjectOrNull(body));
[Theory]
[InlineData("{}")]
[InlineData(" { \"a\" : 1 } ")]
[InlineData("{\"a\":1,}")]
[InlineData("{/*c*/\"a\":[1,2,],// line\n}")]
[InlineData("// leading\n{\"a\":1}")]
public void ParseObjectOrNull_reads_one_object_with_comments_and_trailing_commas(string body)
=> Assert.Equal(JsonValueKind.Object, UpstreamJson.ParseObjectOrNull(body)!.Value.ValueKind);
[Fact]
public void ParseObjectOrNull_returns_an_element_that_outlives_the_parse()
{
// The document is disposed inside the call; only a cloned root stays readable.
var json = Obj("""{"a":"b","list":[{"c":"d"}]}""");
GC.Collect();
Assert.Equal("b", json.GetProperty("a").GetString());
Assert.Equal("d", json.GetProperty("list")[0].GetProperty("c").GetString());
}
// Everything that is not exactly one object throws JsonException, the type the callers catch. The syntax
// only Newtonsoft tolerated (single quotes, bare names, NaN, hex, undefined, raw line breaks in strings)
// is malformed too; Graph and Entra never send it.
[Theory]
[InlineData("[]")]
[InlineData("[{}]")]
[InlineData("42")]
[InlineData("\"text\"")]
[InlineData("true")]
[InlineData("not json")]
[InlineData("<html>busy</html>")]
[InlineData("{")]
[InlineData("{\"a\":}")]
[InlineData("{} x")]
[InlineData("{}{}")]
[InlineData("{} /* unterminated")]
[InlineData("{'a':'b'}")]
[InlineData("{a:1}")]
[InlineData("{\"a\":NaN}")]
[InlineData("{\"a\":0x1F}")]
[InlineData("{\"a\":undefined}")]
[InlineData("{\"a\":\"line\nbreak\"}")]
[InlineData("{\"a\":1,,}")]
public void ParseObjectOrNull_throws_JsonException_for_anything_but_one_object(string body)
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(body));
[Fact]
public void ParseObjectOrNull_throws_JsonException_for_nesting_beyond_the_depth_limit()
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(
"{\"a\":" + new string('[', 100) + new string(']', 100) + "}"));
// ── GetString ───────────────────────────────────────────────────────────────
[Theory]
[InlineData("""{"a":"value"}""", "value")]
[InlineData("""{"a":""}""", "")]
[InlineData("""{"a":" padded "}""", " padded ")]
[InlineData("""{"a":"quote\" back\\ slash\n tab\t é 😀"}""", "quote\" back\\ slash\n tab\t é 😀")]
[InlineData("""{"a":"2026-01-01T00:00:00Z"}""", "2026-01-01T00:00:00Z")]
[InlineData("""{"a":"/Date(1713096021000)/"}""", "/Date(1713096021000)/")]
[InlineData("""{"a":"42"}""", "42")]
[InlineData("""{"a":"first","a":"last"}""", "last")]
public void GetString_reads_text_as_sent(string json, string expected)
=> Assert.Equal(expected, UpstreamJson.GetString(Obj(json), "a"));
[Theory]
[InlineData("""{}""")]
[InlineData("""{"a":null}""")]
[InlineData("""{"a":42}""")]
[InlineData("""{"a":4.2}""")]
[InlineData("""{"a":true}""")]
[InlineData("""{"a":false}""")]
[InlineData("""{"a":{}}""")]
[InlineData("""{"a":[]}""")]
[InlineData("""{"a":["x"]}""")]
[InlineData("""{"A":"other case"}""")]
[InlineData("""{"a ":"other name"}""")]
public void GetString_reads_anything_but_text_under_the_exact_name_as_absent(string json)
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
[Theory]
[InlineData("null")]
[InlineData("42")]
[InlineData("\"text\"")]
[InlineData("[]")]
public void GetString_on_a_list_entry_that_is_no_object_throws_InvalidOperationException(string entry)
{
// The validators rely on this: a non-object entry before the match keeps ending the lookup as transient.
var item = Obj($$"""{"value":[{{entry}}]}""").GetProperty("value")[0];
Assert.Throws<InvalidOperationException>(() => UpstreamJson.GetString(item, "a"));
}
[Theory]
[InlineData("""{"a":"x\ud800y"}""")]
[InlineData("""{"a":"\udc00"}""")]
[InlineData("""{"a":"\ud800A"}""")]
public void GetString_reads_text_with_a_broken_surrogate_escape_as_absent(string json)
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
// ── Items ───────────────────────────────────────────────────────────────────
[Fact]
public void Items_yields_every_array_entry_in_order_whatever_its_kind()
=> Assert.Equal(
new[] { JsonValueKind.Object, JsonValueKind.Null, JsonValueKind.Number, JsonValueKind.String, JsonValueKind.Array },
UpstreamJson.Items(Obj("""{"value":[{},null,1,"x",[]]}"""), "value").Select(i => i.ValueKind));
[Theory]
[InlineData("""{}""")]
[InlineData("""{"value":null}""")]
[InlineData("""{"value":{}}""")]
[InlineData("""{"value":"oops"}""")]
[InlineData("""{"value":42}""")]
[InlineData("""{"value":[]}""")]
[InlineData("""{"Value":[1]}""")]
public void Items_is_empty_unless_the_property_is_a_non_empty_array(string json)
=> Assert.Empty(UpstreamJson.Items(Obj(json), "value"));
// ── GetNextLink ─────────────────────────────────────────────────────────────
[Theory]
[InlineData("""{}""", null)]
[InlineData("""{"@odata.nextLink":null}""", null)]
[InlineData("""{"@odata.nextLink":""}""", "")]
[InlineData("""{"@odata.nextLink":"https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb"}""", "https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb")]
[InlineData("""{"@odata.nextlink":"https://graph.microsoft.com/beta/other-case"}""", null)]
public void GetNextLink_reads_the_link_or_its_absence(string json, string? expected)
=> Assert.Equal(expected, UpstreamJson.GetNextLink(Obj(json)));
[Theory]
[InlineData("5")]
[InlineData("true")]
[InlineData("{}")]
[InlineData("[]")]
[InlineData("[\"https://graph.microsoft.com/beta/x\"]")]
[InlineData("\"https://graph.microsoft.com/beta/x\\ud800\"")]
public void GetNextLink_throws_for_a_link_that_is_not_text(string link)
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.GetNextLink(Obj($$"""{"@odata.nextLink":{{link}}}""")));
// ── GetInt64 ────────────────────────────────────────────────────────────────
[Theory]
[InlineData("3599", 3599L)]
[InlineData("0", 0L)]
[InlineData("-5", -5L)]
[InlineData("9223372036854775807", long.MaxValue)]
[InlineData("-9223372036854775808", long.MinValue)]
[InlineData("\"3599\"", 3599L)]
[InlineData("\" 3599 \"", 3599L)]
[InlineData("\"+3599\"", 3599L)]
[InlineData("\"-1\"", -1L)]
public void GetInt64_reads_a_whole_number_or_its_text(string value, long expected)
=> Assert.Equal(expected, UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
[Theory]
[InlineData("3599.5")]
[InlineData("3599.0")]
[InlineData("7e2")]
[InlineData("9223372036854775808")]
[InlineData("99999999999999999999")]
[InlineData("\"3599.0\"")]
[InlineData("\"3,599\"")]
[InlineData("\"3.599\"")]
[InlineData("\"0x10\"")]
[InlineData("\"\"")]
[InlineData("\"abc\"")]
[InlineData("\"\u0661\u0662\u0663\"")]
[InlineData("\"\\ud800\"")]
[InlineData("null")]
[InlineData("true")]
[InlineData("{}")]
[InlineData("[]")]
public void GetInt64_reads_anything_but_a_whole_number_as_absent(string value)
=> Assert.Null(UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
[Fact]
public void GetInt64_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetInt64(Obj("{}"), "n"));
// ── GetBoolean ──────────────────────────────────────────────────────────────
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
[InlineData("null", null)]
[InlineData("\"true\"", null)]
[InlineData("\"false\"", null)]
[InlineData("1", null)]
[InlineData("0", null)]
[InlineData("{}", null)]
public void GetBoolean_reads_only_true_and_false(string value, bool? expected)
=> Assert.Equal(expected, UpstreamJson.GetBoolean(Obj($$"""{"b":{{value}}}"""), "b"));
[Fact]
public void GetBoolean_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetBoolean(Obj("{}"), "b"));
// ── GetUtcDateTime ──────────────────────────────────────────────────────────
[Theory]
[InlineData("2026-05-08T12:16:49Z", 0L)]
[InlineData("2026-05-08T12:16:49.1234567Z", 1234567L)]
[InlineData("2026-05-08T12:16:49.5Z", 5000000L)]
[InlineData("2026-05-08T14:16:49+02:00", 0L)]
[InlineData("2026-05-08T07:16:49.1234567-05:00", 1234567L)]
[InlineData("2026-05-08T12:16:49", 0L)]
public void GetUtcDateTime_reads_the_instant_in_utc_with_full_precision(string value, long fractionTicks)
{
var date = UpstreamJson.GetUtcDateTime(Obj($$"""{"d":"{{value}}"}"""), "d");
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(fractionTicks), date);
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
}
[Fact]
public void GetUtcDateTime_reads_a_date_without_time_as_its_utc_midnight()
=> Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(Obj("""{"d":"2026-05-08"}"""), "d"));
// Graph sends 0001-01-01 for a date it does not have: absence, not a date two thousand years ago.
[Theory]
[InlineData("\"0001-01-01T00:00:00Z\"")]
[InlineData("\"0001-01-01T00:00:00\"")]
[InlineData("\"0001-01-01\"")]
[InlineData("\"\"")]
[InlineData("\" \"")]
[InlineData("\"not-a-date\"")]
[InlineData("\"2026-13-01T00:00:00Z\"")]
[InlineData("\"/Date(1713096021000)/\"")]
[InlineData("null")]
[InlineData("20260508")]
[InlineData("true")]
[InlineData("{}")]
public void GetUtcDateTime_reads_an_unset_or_unusable_value_as_absent(string value)
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj($$"""{"d":{{value}}}"""), "d"));
[Fact]
public void GetUtcDateTime_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj("{}"), "d"));
// ── Culture matrix ──────────────────────────────────────────────────────────
// No member may depend on the host culture: day-first, Thai Buddhist calendar and the en-US default all
// read the same body the same way.
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Every_member_reads_the_same_in_any_culture(string culture)
{
var saved = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
var json = Obj("""{"d":"2026-05-08T12:16:49.1234567Z","day":"2026-05-08","n":"3599","t":"2026-05-08T12:16:49Z","f":"1.5"}""");
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), UpstreamJson.GetUtcDateTime(json, "d"));
Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(json, "day"));
Assert.Equal(3599L, UpstreamJson.GetInt64(json, "n"));
Assert.Equal("2026-05-08T12:16:49Z", UpstreamJson.GetString(json, "t"));
Assert.Null(UpstreamJson.GetInt64(json, "f"));
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
}
@@ -1,65 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// One canonical text per result of the Graph and Entra token-endpoint parsers (D-420), so two readings of a
/// body compare as strings: the differential test compares production with the Newtonsoft reference, the
/// invariant fuzz compares a body with its reformatted twin.
/// </summary>
internal static class UpstreamParserReadings
{
internal const string Serial = "PF3XKQ7";
internal const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
internal static readonly string CorporateId =
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
/// <summary>The reading, or "&lt;throws&gt;" when the parser throws.</summary>
internal static string Outcome(Func<string> run)
{
try { return run(); }
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) { return "<throws>"; }
}
private static string Text(string? value) => value is null ? "<null>" : "\"" + value + "\"";
private static string Date(DateTime? value) => value is { } d ? d.ToString("O", CultureInfo.InvariantCulture) + "/" + d.Kind : "<null>";
// Every consumer reads the token with IsNullOrEmpty, so an empty and an absent token describe alike.
internal static string Describe((string? AccessToken, long? ExpiresInSeconds) r) =>
$"token={(string.IsNullOrEmpty(r.AccessToken) ? "<none>" : Text(r.AccessToken))} expires={r.ExpiresInSeconds?.ToString(CultureInfo.InvariantCulture) ?? "<null>"}";
internal static string Describe(EntraTokenRejection r) => $"status={r.StatusCode} code={Text(r.ErrorCode)} description={Text(r.Description)}";
internal static string Describe(AutopilotMonitor.Shared.Models.AutopilotRegistration? r) => r is null
? "<not registered>"
: $"id={Text(r.DeviceId)} tag={Text(r.GroupTag)} po={Text(r.PurchaseOrderId)} name={Text(r.DisplayName)} user={r.UserAssigned}";
internal static string Describe((bool Found, string NextUrl) r) => $"found={r.Found} next={Text(r.NextUrl)}";
internal static string Describe(DeviceAssociationResult r) =>
$"valid={r.IsValid} transient={r.IsTransient} serial={Text(r.SerialNumber)} state={Text(r.AssociationState)} policy={Text(r.DevicePreparationPolicyId)} " +
$"preBy={Text(r.PreAssociatedByUserPrincipalName)} assigned={Text(r.AssignedToUserPrincipalName)} pre={Date(r.PreAssociationDateTime)} " +
$"at={Date(r.AssociationDateTime)} device={Text(r.ManagedDeviceId)} error={Text(r.ErrorMessage)}";
internal static string Describe(CloudPcValidationResult r) =>
$"valid={r.IsValid} transient={r.IsTransient} device={Text(r.IntuneDeviceId)} cpc={Text(r.CloudPcId)} name={Text(r.ManagedDeviceName)} " +
$"plan={Text(r.ServicePlanName)} error={Text(r.ErrorMessage)}";
internal static string Describe(IntuneDeviceBindingResult r) =>
$"outcome={r.Outcome} device={Text(r.IntuneDeviceId)} name={Text(r.DeviceName)} aad={Text(r.AzureAdDeviceId)} state={Text(r.ManagementState)} " +
$"serial={Text(r.SerialNumber)} owner={Text(r.OwnerType)} enrollment={Text(r.EnrollmentType)} registered={r.AzureAdRegistered?.ToString() ?? "<null>"} " +
$"enrolled={(r.EnrolledDateTime is { } e ? e.ToString("O", CultureInfo.InvariantCulture) : "<null>")} error={Text(r.ErrorMessage)}";
// A null or empty next link both end the full pull, so they describe alike.
internal static string Describe((List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) r) =>
string.Join(";", r.Scripts.Select(s => $"{Text(s.Id)}={Text(s.DisplayName)}/{Text(s.FileName)}")) + $" next={Text(string.IsNullOrEmpty(r.NextUrl) ? null : r.NextUrl)}";
internal static string Describe(string? scriptName) => scriptName ?? "<null>";
}
@@ -1,231 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Deterministic Graph and Entra token-endpoint response bodies for the parser tests of D-420. They vary root
/// kind, empty, broken and cut-off bodies, trailing content, comments, trailing commas, whitespace, property
/// order, unknown and duplicate properties, mixed list entries, text with escapes and Unicode, paging links,
/// lifetimes as number or numeric text, and error-code variants.
/// By default they leave out the inputs of the deliberate differences D1 to D6, so the old and the new parser
/// must agree on them. With <c>everyValueKind</c> they add those inputs too: JSON null, numbers, booleans,
/// objects and arrays in text fields, date-shaped text, dates with fractions and offsets, escapes that are no
/// valid UTF-16, unusable lifetimes and error codes.
/// </summary>
internal sealed class UpstreamResponseCases
{
// The same few literals come back thousands of times; serializing each once keeps the fuzz fast. Declared
// first: the pools below call Str while the type initializes.
private static readonly ConcurrentDictionary<string, string> Serialized = new(StringComparer.Ordinal);
private readonly Random _rng;
private readonly bool _everyValueKind;
public UpstreamResponseCases(int seed, bool everyValueKind = false)
{
_rng = new Random(seed);
_everyValueKind = everyValueKind;
}
/// <summary>Whether the last body was the object cut off halfway (D7).</summary>
public bool LastWasCutOff { get; private set; }
// Plain text that is never a date in either library and never JSON null.
private static readonly string[] PlainTexts =
{
Str("value"), Str(" padded "), Str("UPPER lower 123"), "\"Ünïcødé ☃ ε\"", Str("quote\" back\\ slash\nnew line"),
Str(new string('x', 300)), Str(""), Str(" "), Str("a/b?c=d&e"), Str("é\u0001\u007f"), Str("2026"), Str("12:16"),
};
// What D1, D3 and D4 read differently: JSON null, other kinds, date-shaped text, an escape that is no valid UTF-16.
private static readonly string[] DeliberateTexts =
{
"null", "42", "-1.5", "true", "{}", "[]", "[\"x\"]", Str("2026-01-01T00:00:00Z"), Str("/Date(1713096021000)/"), "\"x\\ud800y\"",
};
// A whole-second UTC date, a date without time, or a value both read as no date.
private static readonly string[] Dates =
{
Str("2026-05-08T12:16:49Z"), Str("2025-12-31T23:59:59Z"), Str("2026-05-08"), Str("0001-01-01T00:00:00Z"),
Str(""), Str("not-a-date"), "null",
};
// What D2 reads differently (fraction, offset, no offset) and dates that are no text.
private static readonly string[] DeliberateDates =
{
Str("2026-05-08T12:16:49.1234567Z"), Str("2026-05-08T14:16:49+02:00"), Str("2026-05-08T12:16:49"), "20260508", "true",
};
private T Pick<T>(params T[] items) => items[_rng.Next(items.Length)];
/// <summary>One of <paramref name="values"/>; with every value kind, one of those or of <paramref name="deliberate"/>.</summary>
private string Value(string[] values, params string[] deliberate)
{
var i = _rng.Next(values.Length + (_everyValueKind ? deliberate.Length : 0));
return i < values.Length ? values[i] : deliberate[i - values.Length];
}
private bool Chance(int percent) => _rng.Next(100) < percent;
private static string Str(string s) => Serialized.GetOrAdd(s, value => System.Text.Json.JsonSerializer.Serialize(value));
private string Ws() => Pick("", "", "", " ", "\n", "\r\n ", "\t");
private string Text() => Value(PlainTexts, DeliberateTexts);
private string Url() => Pick(
Str("https://graph.microsoft.com/beta/next?$skiptoken=abc"), Str("https://graph.microsoft.com/v1.0/x?$top=5"));
private string DateValue() => Value(Dates, DeliberateDates);
/// <summary>A property: (name, json) or (name, null) when the property is left out.</summary>
private (string Name, string? Json) Prop(string name, string json, int missingPercent = 15)
=> (name, Chance(missingPercent) ? null : json);
private string Obj(params (string Name, string? Json)[] props)
{
var parts = props.Where(p => p.Json != null).Select(p => $"{Str(p.Name)}:{Ws()}{p.Json}").ToList();
if (Chance(10))
parts.Insert(_rng.Next(parts.Count + 1), $"{Str("unknownProperty")}:{Pick("1", "\"x\"", "{}", "[]", "null", "true")}");
if (Chance(5) && parts.Count > 0)
{
// Duplicate key with the same value: whichever occurrence wins, the meaning stays.
parts.Insert(0, parts[_rng.Next(parts.Count)]);
}
if (Chance(30))
parts = parts.OrderBy(_ => _rng.Next()).ToList();
var separator = Pick(",", ",", ", ", ",\n ", ", /*c*/ ", ",// line\n");
var trailing = parts.Count > 0 && Chance(5) ? "," : "";
return "{" + Ws() + string.Join(separator, parts) + trailing + Ws() + "}";
}
private string ValueList(Func<string> item)
{
switch (_rng.Next(100))
{
case < 5: return "null";
case < 9: return Pick("\"oops\"", "{}", "42");
case < 14: return "[]";
}
var entries = new List<string>();
var count = _rng.Next(1, 6);
for (var i = 0; i < count; i++)
entries.Add(_rng.Next(100) switch { < 85 => item(), < 90 => "null", < 94 => Pick("1", "\"x\"", "true"), < 97 => "[]", _ => "{}" });
return "[" + Ws() + string.Join("," + Ws(), entries) + Ws() + "]";
}
/// <summary>The body around an object: mostly the object itself, else a root or shape both refuse.</summary>
private string Root(string objectJson)
{
var pick = _rng.Next(100);
LastWasCutOff = pick is >= 92 and < 95;
return pick switch
{
< 82 => Ws() + objectJson + Ws(),
< 84 => "",
< 86 => " ",
< 88 => "null",
< 90 => "[" + objectJson + "]",
< 92 => Pick("42", "\"text\"", "true"),
< 95 => objectJson.Substring(0, Math.Max(1, objectJson.Length / 2)),
< 98 => objectJson + Pick(" x", " {}", " 1"),
_ => Pick("<html>busy</html>", "not json"),
};
}
private string Matching(string target) => Value(
new[] { Str(target), Str(target.ToLowerInvariant()), Str(" " + target + " "), Str(target + "X"), Str("OTHER"), Str(""), "12345", "true" },
"null", "{}", "[" + Str(target) + "]", "\"" + target + "\\ud800\"");
public string TokenBody() => Root(Obj(
Prop("access_token", Value(new[] { Str("eyJ0eXAi.token"), Str("t"), Str(""), "null" }, "42", "{}", "\"t\\ud800\"")),
Prop("expires_in", Value(
new[] { "3599", "0", "-5", "7200", "9223372036854775807", "\"3599\"", "\" 3599 \"", "\"+3599\"", "\"-1\"", "null" },
"\"abc\"", "\"\"", "3599.5", "7e2", "true", "99999999999999999999", "{}", "[]", "\"\\ud800\"")),
Prop("token_type", Str("Bearer"))));
public string EntraBody() => Root(Obj(
Prop("error", Pick(Str("invalid_client"), Str("unauthorized_client"))),
Prop("error_description", Value(
new[]
{
Str("AADSTS7000229: The client application is missing a service principal.\r\nTrace ID: x"),
Str("AADSTS53003: Access has been blocked by Conditional Access policies."), Str("no code here"), Str("AADSTS: no digits"),
Str(new string('d', 400)), Str(" "), "null", "42", "true", "{}", "[]",
},
Str("2026-11-01T08:00:00Z"), "\"AADSTS53003: broken \\ud800\"")),
Prop("error_codes", Value(
new[] { "[7000229]", "[53003,7000229]", "[\"7000229\",53003]", "[7000229.5]", "[7e6]", "[-1]", "[]", "7000229", "null", "[" + _rng.Next(1, 99_999_999) + "]" },
"[99999999999999999999,53003]", "[1e3]")),
Prop("timestamp", Str("2026-11-05 08:00:00Z"))));
public string AutopilotBody(string serial) => Root(Obj(
Prop("@odata.context", Str("https://graph.microsoft.com/v1.0/$metadata#deviceManagement/windowsAutopilotDeviceIdentities")),
Prop("value", ValueList(() => Obj(
Prop("id", Pick(Text(), Str("ap-1"), "null", "42")),
Prop("serialNumber", Matching(serial), 10),
Prop("groupTag", Pick(Text(), "null", "true")),
Prop("purchaseOrderIdentifier", Pick(Text(), "null", "{}")),
Prop("displayName", Pick(Text(), "null", "[]")),
Prop("userPrincipalName", Pick(Text(), "null", Str("u@contoso.com"))))), 5)));
public string CorporateBody()
{
var identifier = Value(
new[]
{
Str("Microsoft Corporation,Virtual Machine,1234-5678"), Str("MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"),
Str("microsoft corporation, virtual machine, 1234 5678"), Str("Other,Model,1"), Str(""), "null", "42",
},
"[" + Str("Microsoft Corporation,Virtual Machine,1234-5678") + "]", "true", "\"Microsoft Corporation,Virtual Machine,1234-5678\\ud800\"");
return Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("importedDeviceIdentityType", Value(new[] { Str("manufacturerModelSerial"), Str("MANUFACTURERMODELSERIAL"), Str("serialNumber"), "null" }, "42")),
Prop("importedDeviceIdentifier", identifier))), 5),
Prop("@odata.nextLink", Value(new[] { Url(), Str(""), "null" }, "5", "true", "{}", "\"https://graph.microsoft.com/beta/next\\ud800\""), 50)));
}
public string AssociationBody(string serial) => Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("serialNumber", Matching(serial), 10),
Prop("associationState", Pick(Str("associated"), Str("preAssociated"), Text())),
Prop("devicePreparationPolicyId", Text()),
Prop("preassociatedByUserPrincipalName", Text()),
Prop("assignedToUserPrincipalName", Text()),
Prop("preassociationDateTime", DateValue()),
Prop("associationDateTime", DateValue()),
Prop("managedDeviceId", Text()))), 5)));
public string CloudPcBody(string intuneId) => Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("id", Text()),
Prop("managedDeviceId", Matching(intuneId), 10),
Prop("managedDeviceName", Text()),
Prop("servicePlanName", Text()))), 5)));
public string ManagedDeviceBody(string intuneId) => Root(Obj(
Prop("id", Matching(intuneId), 10),
Prop("deviceName", Text()),
Prop("azureADDeviceId", Text()),
Prop("managementState", Pick(Str("managed"), Str("retirePending"), Str("WIPEISSUED"), Str("deletePending"), Text())),
Prop("serialNumber", Pick(Text(), "null")),
Prop("managedDeviceOwnerType", Pick(Str("company"), Text(), "null")),
Prop("deviceEnrollmentType", Pick(Str("windowsAzureADJoin"), Text(), "null")),
Prop("azureADRegistered", Value(new[] { "true", "false", "null", "\"true\"", "1" }, "\"false\"", "0", "{}")),
Prop("enrolledDateTime", DateValue())));
public string ScriptPageBody() => Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("id", Value(new[] { Str("s1"), Str("4f9e2c1a-0000-0000-0000-000000000001"), Str(""), "null" }, "42", "{}", "\"s1\\ud800\""), 10),
Prop("displayName", Text()),
Prop("fileName", Text()))), 5),
Prop("@odata.nextLink", Value(new[] { Url(), Str(""), "null" }, "5", "{}", "\"https://graph.microsoft.com/beta/p2\\ud800\""), 50)));
public string ScriptNameBody() => Root(Obj(
Prop("id", Str("s1")),
Prop("displayName", Text()),
Prop("fileName", Text())));
}
@@ -1,49 +0,0 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// What runs while a Global Admin views a tenant as its member (read-only "view as"). The class follows the
/// method — GET reads, everything else writes and is refused — so a new route fails closed. Every exception is
/// listed here with its reason: a POST that only reads, or a GET that starts an action. A new exception needs a
/// deliberate line, and a GET that writes must either be listed or skip its write under view-as in the handler
/// (RecomputeTriggerGate, the consent-status auto-flip).
/// </summary>
public class ViewAsAccessCatalogTests
{
private static readonly IReadOnlyDictionary<string, string> Exceptions = new Dictionary<string, string>(StringComparer.Ordinal)
{
["PUT auth/me/whats-new-seen"] = "the caller's own What's-new marker under the JWT tenant; opening What's new sends it",
["POST realtime/negotiate"] = "a live-update subscription, nothing stored",
["POST realtime/groups/join"] = "a live-update subscription, nothing stored",
["POST realtime/groups/leave"] = "a live-update subscription, nothing stored",
["POST diagnostics/download-ticket"] = "a stateless HMAC ticket for a read",
["POST rules/analyze/dryrun"] = "evaluates a draft rule against a session and persists nothing",
["POST rules/gather/test-pattern"] = "a regex against pasted lines, no tenant data",
["POST tenants/{tenantId}/scripts/display-names"] = "a read sent as POST for its body size",
["POST tenants/{tenantId}/graph-permissions/refresh"] = "clears the in-memory Graph cache only",
["GET config/{tenantId}/autopilot-device-validation/consent-url"] = "starts the admin-consent flow (ops events with the caller's UPN)",
};
private static ViewAsAccess MethodDefault(string method) => method == "GET" ? ViewAsAccess.Read : ViewAsAccess.Write;
[Fact]
public void Every_class_that_differs_from_the_method_default_is_listed_with_a_reason()
{
var actual = EndpointAccessPolicyCatalog.Entries
.Where(e => e.ViewAsAccess != MethodDefault(e.HttpMethod))
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
.OrderBy(k => k, StringComparer.Ordinal);
Assert.Equal(Exceptions.Keys.OrderBy(k => k, StringComparer.Ordinal), actual);
}
[Theory]
[InlineData("GET", ViewAsAccess.Read)]
[InlineData("post", ViewAsAccess.Write)]
[InlineData("PUT", ViewAsAccess.Write)]
[InlineData("PATCH", ViewAsAccess.Write)]
[InlineData("DELETE", ViewAsAccess.Write)]
public void An_unclassified_route_follows_its_method(string method, ViewAsAccess expected)
=> Assert.Equal(expected, new EndpointPolicyEntry(method, "some/route", EndpointPolicy.MemberRead).ViewAsAccess);
}
@@ -1,167 +0,0 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Telemetry;
using AutopilotMonitor.Shared;
using Microsoft.ApplicationInsights.Channel;
using Microsoft.ApplicationInsights.DataContracts;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The pieces of "view as tenant" outside the policy decision: the header parse, the data scope every handler
/// reads (<see cref="TenantHelper"/>), the recompute gate that guards two GET actions, and the places where the
/// real caller — not the viewed tenant — must count (presence, the request row, every other telemetry item).
/// </summary>
public class ViewAsTests
{
private const string Home = "11111111-1111-1111-1111-111111111111";
private const string Viewed = "22222222-2222-2222-2222-222222222222";
private const string GaUpn = "ga@vendor.example";
private static RequestContext ViewingContext(string role = Constants.TenantRoles.Admin) => new()
{
TenantId = Viewed,
TargetTenantId = Viewed,
UserPrincipalName = GaUpn,
UserRole = role,
IsTenantAdmin = role == Constants.TenantRoles.Admin,
ViewAs = new ViewAsContext(Home, role),
};
// ── header parse ─────────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void No_tenant_header_is_an_ordinary_request(string? tenantHeader)
=> Assert.Null(ViewAsTarget.FromHeaders(tenantHeader, "Viewer"));
[Theory]
[InlineData("not-a-guid", null)]
[InlineData("00000000-0000-0000-0000-000000000000", null)]
[InlineData(Viewed, "GlobalAdmin")]
[InlineData(Viewed, "Member")]
public void Unusable_headers_are_invalid(string tenantHeader, string? roleHeader)
=> Assert.Same(ViewAsTarget.Invalid, ViewAsTarget.FromHeaders(tenantHeader, roleHeader));
[Theory]
[InlineData(null, Constants.TenantRoles.Admin)]
[InlineData("viewer", Constants.TenantRoles.Viewer)]
[InlineData(" OPERATOR ", Constants.TenantRoles.Operator)]
public void A_tenant_id_is_normalized_and_the_role_defaults_to_admin(string? roleHeader, string expectedRole)
{
var request = ViewAsTarget.FromHeaders(" 22222222-2222-2222-2222-22222222222A ", roleHeader);
Assert.Equal(new ViewAsTarget("22222222-2222-2222-2222-22222222222a", expectedRole), request);
Assert.True(request!.IsValid);
}
// ── data scope and identity ──────────────────────────────────────────────
[Fact]
public void TenantHelper_answers_the_viewed_tenant_under_view_as_and_the_token_tenant_otherwise()
{
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", Home), new Claim("upn", GaUpn) }, "test"));
Assert.Equal(Viewed, TenantHelper.GetTenantId(Request(principal, ViewingContext())));
Assert.Equal(Home, TenantHelper.GetTenantId(Request(principal, new RequestContext { TenantId = Home })));
Assert.Equal(Home, TenantHelper.GetTenantId(Request(principal, context: null)));
}
[Fact]
public void The_identity_tenant_stays_the_home_tenant()
{
Assert.Equal(Home, ViewingContext().IdentityTenantId());
Assert.Equal(Viewed, new RequestContext { TenantId = Viewed }.IdentityTenantId());
}
[Fact]
public void View_as_never_triggers_a_recompute_although_its_admin_owns_the_viewed_tenant()
{
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ViewingContext(), Viewed));
Assert.True(RecomputeTriggerGate.CanTriggerRecompute(ViewingContext() with { ViewAs = null }, Viewed));
}
// ── counting at the real caller ──────────────────────────────────────────
[Fact]
public void View_as_records_no_presence_in_the_viewed_tenant()
{
Assert.False(UserPresenceMiddleware.Records(ViewingContext()));
Assert.True(UserPresenceMiddleware.Records(ViewingContext() with { ViewAs = null }));
}
[Fact]
public void The_request_row_of_a_view_as_request_counts_at_the_home_tenant_as_global_admin()
{
var ctx = ViewingContext();
var properties = new Dictionary<string, string>();
RequestTelemetryMiddleware.StampCaller(properties, ctx,
RequestTelemetryMiddleware.ResolveTenantId(ctx.IdentityTenantId(), new Dictionary<object, object>(), "/api/sessions", null));
Assert.Equal(Home, properties[RequestRowDimensions.TenantId]);
Assert.Equal(GaUpn, properties[RequestRowDimensions.UserId]);
Assert.Equal(Constants.GlobalRoles.GlobalAdmin, properties[RequestRowDimensions.UserRole]);
Assert.Equal(Viewed, properties[RequestRowDimensions.ViewAsTenantId]);
}
[Fact]
public void An_ordinary_request_row_carries_no_view_as_dimension()
{
var ctx = ViewingContext() with { TenantId = Home, ViewAs = null };
var properties = new Dictionary<string, string>();
RequestTelemetryMiddleware.StampCaller(properties, ctx, ctx.TenantId);
Assert.Equal(Home, properties[RequestRowDimensions.TenantId]);
Assert.Equal(Constants.TenantRoles.Admin, properties[RequestRowDimensions.UserRole]);
Assert.False(properties.ContainsKey(RequestRowDimensions.ViewAsTenantId));
}
[Fact]
public async Task Telemetry_items_are_stamped_only_inside_the_view_as_request_flow()
{
var sink = new CapturingProcessor();
var processor = new ViewAsTelemetryProcessor(sink);
await InViewAsFlow(() => processor.Process(new EventTelemetry("inside")));
processor.Process(new EventTelemetry("outside"));
var inside = (EventTelemetry)sink.Items[0];
var outside = (EventTelemetry)sink.Items[1];
Assert.Equal(Viewed, inside.Properties[RequestRowDimensions.ViewAsTenantId]);
Assert.False(outside.Properties.ContainsKey(RequestRowDimensions.ViewAsTenantId));
}
/// <summary>The scope opens inside an awaited method, like the middleware; it ends with that flow.</summary>
private static async Task InViewAsFlow(Action emit)
{
await Task.Yield();
ViewAsTelemetryProcessor.Enter(Viewed);
emit();
}
private sealed class CapturingProcessor : ITelemetryProcessor
{
public List<ITelemetry> Items { get; } = new();
public void Process(ITelemetry item) => Items.Add(item);
}
private static HttpRequestData Request(ClaimsPrincipal principal, RequestContext? context)
{
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
if (context != null)
items[RequestContext.ItemsKey] = context;
var functionContext = new Mock<FunctionContext>();
functionContext.SetupGet(c => c.Items).Returns(items);
return new Mock<HttpRequestData>(functionContext.Object).Object;
}
}
@@ -39,26 +39,23 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
<PackageReference Include="Azure.Monitor.Query" Version="1.5.0" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.0.1" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.5" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.1.0" />
<PackageReference Include="Azure.Data.Tables" Version="12.13.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
<PackageReference Include="Azure.Storage.Queues" Version="12.28.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.4" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageReference Include="Azure.Storage.Queues" Version="12.25.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.16.1" />
<!-- Transitive via Microsoft.Identity.Web's token cache, lifted past GHSA-846f-f764-76mx and
GHSA-2fmv-rv2c-59x7. Remove once Microsoft.Identity.Web is 4.17.0 or later (it brings 10.0.13). -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
</ItemGroup>
<ItemGroup>
@@ -93,9 +93,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
=> _storage.GetSignInIdentitiesByUpnAsync(upn);
public Task<List<PortalVisit>> GetPortalVisitsSinceAsync(DateTime sinceUtc)
=> _storage.GetPortalVisitsSinceAsync(sinceUtc);
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
@@ -88,6 +88,44 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return records;
}
public async Task<List<UserUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
{
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
var records = new List<UserUsageRecord>();
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
{
records.Add(MapToRecord(entity));
}
return records;
}
public async Task<List<UserUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null)
{
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
var records = new List<UserUsageRecord>();
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
{
records.Add(MapToRecord(entity));
}
var grouped = records
.GroupBy(r => r.Date)
.Select(g => new UserUsageDailySummary
{
Date = g.Key,
TenantId = tenantId,
TotalRequests = g.Sum(r => r.RequestCount),
UniqueUsers = g.Select(r => r.UserId).Distinct().Count(),
UniqueEndpoints = g.Select(r => r.Endpoint).Distinct().Count(),
})
.OrderByDescending(s => s.Date)
.ToList();
return grouped;
}
/// <summary>
/// Builds the OData filter for a per-user usage query. <paramref name="userId"/> is a route
/// segment (AAD oid) and the dates are query params, so all values MUST be OData-escaped before
@@ -97,15 +135,42 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
internal static string BuildUserUsageFilter(string userId, string? dateFrom, string? dateTo)
{
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(userId)}'";
return AppendDateFilter(filter, dateFrom, dateTo);
}
/// <summary>
/// Builds the OData filter for a per-tenant usage query. Same escaping requirement as
/// <see cref="BuildUserUsageFilter"/>; returns null when no tenant/date scope is supplied.
/// </summary>
internal static string? BuildTenantUsageFilter(string? tenantId, string? dateFrom, string? dateTo)
{
string? filter = !string.IsNullOrEmpty(tenantId)
? $"TenantId eq '{ODataSanitizer.EscapeValue(tenantId)}'"
: null;
return AppendDateFilter(filter, dateFrom, dateTo);
}
private static string AppendDateFilter(string? existingFilter, string? dateFrom, string? dateTo)
{
var filter = existingFilter ?? "";
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
// so the value must still be OData-escaped before interpolation.
if (!string.IsNullOrEmpty(dateFrom))
filter += $" and Date ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
if (!string.IsNullOrEmpty(dateTo))
filter += $" and Date le '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}'";
{
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
// so the value must still be OData-escaped before interpolation.
var dateVal = ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""));
var clause = $"Date ge '{dateVal}'";
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
}
return filter;
if (!string.IsNullOrEmpty(dateTo))
{
var dateVal = ODataSanitizer.EscapeValue(dateTo.Replace("-", ""));
var clause = $"Date le '{dateVal}'";
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
}
return string.IsNullOrEmpty(filter) ? null! : filter;
}
public async Task<int> DeleteRecordsOlderThanAsync(string dateCutoff)
@@ -162,7 +162,8 @@ public class AutopilotDeviceValidationConsentFunction
// here (flag-gated, probe-verified, idempotent) makes the status below resolve the newly
// homed app naturally. Unbudgeted on purpose: this runs in the post-redirect callback,
// where the consent-propagation retry chain is exactly what we want to ride.
var flip = await TryAutoFlipAsync(requestCtx);
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
var flip = await _appHomingService.TryAutoFlipToPrimaryAsync(tenantConfig, requestCtx.UserPrincipalName);
var result = await _graphTokenService.GetConsentStatusAsync(requestCtx.TargetTenantId);
@@ -236,7 +237,9 @@ public class AutopilotDeviceValidationConsentFunction
// Consent-driven homing auto-flip BEFORE the fresh snapshot: the "detect existing
// access" / reconcile path never touches consent-status, so this is its flip hook. A
// budget-exhausted probe is transient ⇒ no flip; the frontend already renders "retry".
flip = await TryAutoFlipAsync(requestCtx, budgetCts.Token);
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
flip = await _appHomingService.TryAutoFlipToPrimaryAsync(
tenantConfig, requestCtx.UserPrincipalName, budgetCts.Token);
GraphPermissionSnapshot snapshot;
try
@@ -273,19 +276,6 @@ public class AutopilotDeviceValidationConsentFunction
return response;
}
/// <summary>
/// The consent-driven homing auto-flip. It writes the tenant's homing with the caller as actor plus an
/// audit entry, so it never runs while a Global Admin only views the tenant (read-only "view as").
/// </summary>
private async Task<AppHomingAutoFlipResult> TryAutoFlipAsync(RequestContext requestCtx, CancellationToken ct = default)
{
if (requestCtx.ViewAs != null)
return new AppHomingAutoFlipResult(AppHomingAutoFlipOutcome.NotEligible);
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
return await _appHomingService.TryAutoFlipToPrimaryAsync(tenantConfig, requestCtx.UserPrincipalName, ct);
}
/// <summary>
/// Pure decision for the access-check probe: does the SP snapshot prove the core validation
/// role is effectively granted? Extracted so the reconcile decision is unit testable without
@@ -110,11 +110,6 @@ public class AuthFunction
return await req.BadRequestAsync("Missing required claims");
}
// A Global Admin viewing a tenant as its member: that tenant's standing, none of the sign-in side effects.
var requestContext = req.GetRequestContext();
if (requestContext.ViewAs != null)
return await GetViewAsUserAsync(req, principal, requestContext, upn, displayName, objectId);
// --- Parallel data fetch: all independent queries run concurrently ---
// Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500.
// This is intentional — the auth-decision queries are required; the What's-new marker read
@@ -236,53 +231,6 @@ public class AuthFunction
return response;
}
/// <summary>
/// auth/me while a Global Admin views a tenant as its member: the viewed tenant's standing (suspension,
/// activation, Terms + DPA, plan) for the emulated member, through the same <see cref="BuildAuthResult"/>.
/// None of the sign-in side effects run — onboarding, re-enable, client-id tracking, auto-admin, the login
/// record and the push sign-in belong to the tenant's own members. Per-user state stays the caller's own:
/// What's-new marks, MCP access and the homed app registration come from the home tenant.
/// </summary>
private async Task<HttpResponseData> GetViewAsUserAsync(
HttpRequestData req, System.Security.Claims.ClaimsPrincipal principal, RequestContext ctx,
string upn, string? displayName, string? objectId)
{
var viewedTenantId = ctx.TenantId;
var homeTenantId = ctx.ViewAs!.HomeTenantId;
var viewedConfigTask = _tenantConfigService.TryGetConfigurationAsync(viewedTenantId);
var homeConfigTask = _tenantConfigService.TryGetConfigurationAsync(homeTenantId);
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(viewedTenantId);
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, homeTenantId, objectId, principal.GetAppRoles());
var whatsNewSeenTask = _metricsRepo.GetUserWhatsNewSeenAsync(homeTenantId, upn);
await Task.WhenAll(viewedConfigTask, homeConfigTask, isApprovedTask, mcpCheckTask, whatsNewSeenTask);
var (viewedConfig, _) = viewedConfigTask.Result;
var (homeConfig, _) = homeConfigTask.Result;
var whatsNewSeen = whatsNewSeenTask.Result;
var decision = BuildAuthResult(
viewedConfig, isGlobalAdmin: false, isGlobalReader: false, isApprovedTask.Result,
new MemberRoleInfo { Role = ctx.ViewAs.Role }, mcpCheckTask.Result, hasTenantAdmins: true,
viewedTenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
delegatedTenantIds: null,
homedApp: _appRegistry.ResolveForTenant(homeConfig).IsLegacy ? "legacy" : "primary",
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc,
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false,
viewAs: new AuthMeViewAs
{
TenantId = viewedTenantId,
TenantName = TenantDisplayName.Of(viewedConfig, viewedTenantId),
Role = ctx.ViewAs.Role,
HomeTenantId = homeTenantId,
});
var response = req.CreateResponse(decision.StatusCode);
await response.WriteAsJsonAsync(decision.Body);
return response;
}
/// <summary>
/// PUT /api/auth/me/whats-new-seen
/// Marks the caller's What's-new channel as seen.
@@ -785,8 +733,7 @@ public class AuthFunction
DateTime? whatsNewSeenPlatformUtc = null,
DateTime? whatsNewSeenAgentUtc = null,
bool mcpClientRegistrationEnabled = false,
bool isExternalIdentity = false,
AuthMeViewAs? viewAs = null)
bool isExternalIdentity = false)
{
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
@@ -889,8 +836,7 @@ public class AuthFunction
McpClientRegistrationEnabled = mcpClientRegistrationEnabled,
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc,
DpaAcceptancePending = tenantConfig.DpaAcceptancePending,
ViewAs = viewAs
DpaAcceptancePending = tenantConfig.DpaAcceptancePending
}, needsAutoAdmin);
}
@@ -56,11 +56,10 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
// revocation enforceable, and only inside the tenant whose grant was revoked
// (LiveStreamRevoker). Both values come from the middleware-validated JWT, never from
// client-controlled data. Without a principal key the connection has no user id: every
// revocable join gate needs one. Under view-as the binding stays the sign-in tenant
// (IdentityTenantId): removing a Global Admin must cut their view-as tab as well.
// revocable join gate needs one.
var requestCtx = req.GetRequestContext();
var principalKey = string.IsNullOrEmpty(requestCtx.UserPrincipalName) ? null : requestCtx.UserPrincipalName;
var negotiation = await _signalRService.NegotiateClientAsync(requestCtx.IdentityTenantId(), principalKey);
var negotiation = await _signalRService.NegotiateClientAsync(requestCtx.TenantId, principalKey);
if (negotiation == null)
{
return await req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable, "SignalR negotiation unavailable");
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
@@ -6,7 +7,6 @@ using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using static AutopilotMonitor.Functions.Functions.Metrics.TenantLifecycleFacts;
namespace AutopilotMonitor.Functions.Functions.Metrics
{
@@ -50,15 +50,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var includeTenants = string.Equals(query["includeTenants"], "true", StringComparison.OrdinalIgnoreCase);
var nowUtc = DateTime.UtcNow;
var yesterday = nowUtc.Date.AddDays(-1);
var configs = await _tenantConfigService.GetAllConfigurationsAsync();
var stats = await _metricsRepo.GetAllTenantStatsAsync();
var activity = await _metricsRepo.GetTenantActivityDaysAsync(
GrowthMetricsBuilder.UsageReadStart(nowUtc.Date, days), yesterday);
var yesterdayAggregated = await _metricsRepo.HasUsageMetricsSnapshotAsync(TenantLifecycleFacts.Key(yesterday));
GrowthMetricsBuilder.UsageReadStart(nowUtc.Date, days), nowUtc.Date.AddDays(-1));
return await req.OkAsync(GrowthMetricsBuilder.Build(
configs, stats, activity, nowUtc, days, timeZone, includeTenants, excludedTenantIds, yesterdayAggregated));
configs, stats, activity, nowUtc, days, timeZone, includeTenants, excludedTenantIds));
}
catch (Exception ex)
{
@@ -79,6 +77,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
internal const int DailyBucketMaxDays = 90;
internal const int PeakBaselineDays = 14;
internal const int PeakMinSignups = 3;
/// <summary>The activity segments look this far back, whatever the window.</summary>
internal const int ActivityDays = 30;
/// <summary>
/// OnboardedAt is the row's insert time from this day on; its one-time backfill gave the older rows
@@ -90,8 +90,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
/// <summary>First UTC day of the daily usage rows the response needs: the window and the activity look-back.</summary>
internal static DateTime UsageReadStart(DateTime today, int days)
=> InclusiveWindowStart(today, Math.Max(days, TenantLifecycleFacts.ActivityDays));
internal static DateTime UsageReadStart(DateTime today, int days) => InclusiveWindowStart(today, Math.Max(days, ActivityDays));
// IANA ids only ("Europe/Berlin", "America/Argentina/Buenos_Aires", "Etc/GMT+1"). On Linux the id
// becomes a path below the zoneinfo folder, so dots and rooted paths never reach the lookup.
@@ -128,10 +127,20 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return true;
}
/// <param name="yesterdayAggregated">
/// Whether yesterday's daily rows exist yet (its platform row); before the first maintenance run after
/// midnight UTC they do not, and the counters alone tell who was active yesterday.
/// </param>
private sealed class TenantFacts
{
public TenantConfiguration Config { get; init; } = default!;
public DateTime? SignupUtc { get; init; }
public bool Enrolled { get; init; }
public bool Succeeded { get; init; }
public DateTime? LastActiveDay { get; init; }
public DateTime? FirstEnrollmentDay { get; init; }
public bool FirstEnrollmentPending { get; init; }
public int? DaysToFirstEnrollment => FirstEnrollmentDay is { } first && SignupUtc is { } signup
? (int)(first - signup.Date).TotalDays
: null;
}
internal static GrowthMetricsResponse Build(
IReadOnlyList<TenantConfiguration> configs,
IReadOnlyList<TenantStats> stats,
@@ -140,24 +149,34 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
int days,
TimeZoneInfo timeZone,
bool includeTenants,
IReadOnlyCollection<string> excludedTenantIds,
bool yesterdayAggregated = true)
IReadOnlyCollection<string> excludedTenantIds)
{
var today = nowUtc.Date;
var windowStart = InclusiveWindowStart(today, days);
var previousStart = windowStart.AddDays(-days);
var usageDaysThrough = TenantLifecycleFacts.UsageDaysThrough(today, yesterdayAggregated);
var statsByTenant = stats
.GroupBy(s => s.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var activeDaysByTenant = activityDays
.GroupBy(a => a.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(a => a.Day.Date).Distinct().OrderBy(d => d).ToList(),
StringComparer.OrdinalIgnoreCase);
var signedUp = TenantLifecycleFacts.SignedUp(configs);
// A configuration row without a domain is a read of an unknown tenant id, not a signup.
var signedUp = configs.Where(c => !string.IsNullOrEmpty(c.DomainName)).ToList();
var excluded = new HashSet<string>(excludedTenantIds, StringComparer.OrdinalIgnoreCase);
var tenants = TenantLifecycleFacts.For(
signedUp.Where(c => !excluded.Contains(c.TenantId)),
stats, activityDays, usageDaysThrough, UsageReadStart(today, days));
var tenants = signedUp
.Where(c => !excluded.Contains(c.TenantId))
.Select(c => Facts(c, statsByTenant, activeDaysByTenant, today))
.ToList();
var window = tenants
.Where(t => t.SignupUtc is { } s && s.Date >= windowStart && s.Date <= today)
.OrderBy(t => t.SignupUtc)
.ToList();
var activeFrom7 = today.AddDays(-6);
var activeFrom30 = today.AddDays(-(ActivityDays - 1));
return new GrowthMetricsResponse
{
@@ -166,7 +185,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
WindowEnd = Key(today),
TimeZone = timeZone.Id,
ExactSignupTimesFrom = Key(ExactSignupTimesFrom),
UsageDaysThrough = Key(usageDaysThrough),
UsageDaysThrough = Key(today.AddDays(-1)),
ExcludedTenants = signedUp.Count - tenants.Count,
Signups = BuildSignups(tenants, window, windowStart, previousStart, today, days, timeZone),
Cohorts = window
@@ -178,7 +197,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
Signups = g.Count(),
Enrolled = g.Count(t => t.Enrolled),
Succeeded = g.Count(t => t.Succeeded),
ActiveLast7Days = g.Count(t => t.ActiveSince(activeFrom7)),
ActiveLast7Days = g.Count(t => t.LastActiveDay >= activeFrom7),
MedianDaysToFirstEnrollment = Median(g.Select(t => t.DaysToFirstEnrollment)),
})
.ToList(),
@@ -201,9 +220,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
NeverEnrolled = tenants.Count(t => !t.Enrolled),
Enrolled = tenants.Count(t => t.Enrolled),
EnrolledWithoutSuccess = tenants.Count(t => t.Enrolled && !t.Succeeded),
Active7Days = tenants.Count(t => t.ActiveSince(activeFrom7)),
Active30Days = tenants.Count(t => t.ActiveSince(TenantLifecycleFacts.ActivityFrom(today))),
Dormant = tenants.Count(t => t.IsDormant(today)),
Active7Days = tenants.Count(t => t.LastActiveDay >= activeFrom7),
Active30Days = tenants.Count(t => t.LastActiveDay >= activeFrom30),
Dormant = tenants.Count(t => t.Enrolled && !(t.LastActiveDay >= activeFrom30)),
},
Plans = new GrowthPlans
{
@@ -233,9 +252,38 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
};
}
private static TenantFacts Facts(
TenantConfiguration config,
IReadOnlyDictionary<string, TenantStats> statsByTenant,
IReadOnlyDictionary<string, List<DateTime>> activeDaysByTenant,
DateTime today)
{
DateTime? signup = config.OnboardedAt is { } at ? Utc(at) : null;
statsByTenant.TryGetValue(config.TenantId, out var counters);
var activeDays = activeDaysByTenant.TryGetValue(config.TenantId, out var found) ? found : new List<DateTime>();
// The daily rows end yesterday; counters that moved today mean sessions today.
var activeToday = counters != null && Utc(counters.LastUpdated) >= today;
DateTime? lastActive = activeToday ? today : activeDays.Count > 0 ? activeDays[^1] : null;
var enrolled = counters?.TotalEnrollments > 0;
var firstDay = signup is { } s
? activeDays.Where(d => d >= s.Date).Select(d => (DateTime?)d).FirstOrDefault()
: null;
return new TenantFacts
{
Config = config,
SignupUtc = signup,
Enrolled = enrolled,
Succeeded = counters?.SuccessfulEnrollments > 0,
LastActiveDay = lastActive,
FirstEnrollmentDay = firstDay,
FirstEnrollmentPending = enrolled && firstDay == null && activeToday,
};
}
private static GrowthSignups BuildSignups(
IReadOnlyList<TenantLifecycleFacts> tenants,
IReadOnlyList<TenantLifecycleFacts> window,
IReadOnlyList<TenantFacts> tenants,
IReadOnlyList<TenantFacts> window,
DateTime windowStart,
DateTime previousStart,
DateTime today,
@@ -297,7 +345,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
private static DateTime WeekStart(DateTime day) => day.AddDays(-(((int)day.DayOfWeek + 6) % 7));
private static DateTime Utc(DateTime value)
=> value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc);
private static string PlanTierOf(TenantConfiguration config)
=> string.IsNullOrEmpty(config.PlanTier) ? "free" : config.PlanTier;
private static string Key(DateTime day) => day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
}
@@ -1,305 +0,0 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using static AutopilotMonitor.Functions.Functions.Metrics.TenantLifecycleFacts;
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// Operator-only tenant activity (GA → Metrics → Tenant Activity and the MCP tool get_tenant_activity): how
/// every signed-up tenant used the product in the last N full UTC days against the N days before —
/// enrollments, success, active days, lifecycle stage and portal users. The configuration rows, the per-tenant
/// counters, the daily usage rows and the portal sign-in history are joined here, once for both consumers.
/// </summary>
public class GetTenantActivityFunction
{
private readonly ILogger<GetTenantActivityFunction> _logger;
private readonly TenantConfigurationService _tenantConfigService;
private readonly IMetricsRepository _metricsRepo;
public GetTenantActivityFunction(
ILogger<GetTenantActivityFunction> logger,
TenantConfigurationService tenantConfigService,
IMetricsRepository metricsRepo)
{
_logger = logger;
_tenantConfigService = tenantConfigService;
_metricsRepo = metricsRepo;
}
[Function("GetTenantActivity")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/tenant-activity")] HttpRequestData req)
{
try
{
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var days = QueryParams.Int(req.Query["days"], TenantActivityBuilder.DefaultWindowDays, 1, TenantActivityBuilder.MaxWindowDays);
var nowUtc = DateTime.UtcNow;
var yesterday = nowUtc.Date.AddDays(-1);
// The configuration rows, the counters and the daily rows are not fail-soft (an empty list
// would read as "no tenant was active"); the sign-in history is optional.
var configsTask = _tenantConfigService.GetAllConfigurationsAsync();
var statsTask = _metricsRepo.GetAllTenantStatsAsync();
var rowsTask = _metricsRepo.GetTenantActivityDaysAsync(TenantActivityBuilder.RowsFrom(nowUtc.Date), yesterday);
var aggregatedTask = _metricsRepo.HasUsageMetricsSnapshotAsync(Key(yesterday));
var visitsTask = ReadPortalVisitsAsync(nowUtc);
await Task.WhenAll(configsTask, statsTask, rowsTask, aggregatedTask, visitsTask);
return await req.OkAsync(TenantActivityBuilder.Build(
await configsTask, await statsTask, await rowsTask, await visitsTask, nowUtc, days, await aggregatedTask));
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "GetTenantActivity");
}
}
/// <summary>The portal visits of the sign-in history, or null when it could not be read.</summary>
private async Task<IReadOnlyList<PortalVisit>?> ReadPortalVisitsAsync(DateTime nowUtc)
{
try
{
return await _metricsRepo.GetPortalVisitsSinceAsync(nowUtc.AddDays(-TenantActivityBuilder.PortalHistoryDays));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[TenantActivity] portal activity read failed");
return null;
}
}
}
/// <summary>
/// Pure response builder — internal static so the counting rules are pinned by tests. The DTOs live in
/// AutopilotMonitor.Shared.Models (Models/Metrics/TenantActivityApiModels.cs); the facts every tenant shares
/// with the growth figures come from <see cref="TenantLifecycleFacts"/>.
/// </summary>
internal static class TenantActivityBuilder
{
internal const int DefaultWindowDays = 30;
/// <summary>The window and the previous window together fit into the 180 days the daily rows keep.</summary>
internal const int MaxWindowDays = DataLifetimes.UsageMetricsDays / 2;
/// <summary>The sign-in history keeps 90 days; the last portal visit is taken from all of it.</summary>
internal const int PortalHistoryDays = 90;
/// <summary>A tenant is declining from this many enrollments in the previous window on.</summary>
internal const int DecliningMinPrevious = 5;
internal const int TopShareTenants = 10;
private static readonly List<TenantActivityDay> NoRows = new();
private static readonly List<PortalVisit> NoVisits = new();
/// <summary>First day of the daily rows the builder reads: all the rows keep, for the first day with sessions.</summary>
internal static DateTime RowsFrom(DateTime today) => today.AddDays(-DataLifetimes.UsageMetricsDays);
private sealed record Window(int Days, DateTime Start, DateTime Through, DateTime PreviousStart)
{
public bool Contains(DateTime day) => day >= Start && day <= Through;
public bool ContainsPrevious(DateTime day) => day >= PreviousStart && day < Start;
}
/// <param name="visits">The portal visits of the sign-in history; null when it could not be read.</param>
/// <param name="yesterdayAggregated">Whether yesterday's daily rows exist yet (its platform row).</param>
internal static GetTenantActivityResponse Build(
IReadOnlyList<TenantConfiguration> configs,
IReadOnlyList<TenantStats> stats,
IReadOnlyList<TenantActivityDay> rows,
IReadOnlyList<PortalVisit>? visits,
DateTime nowUtc,
int days,
bool yesterdayAggregated)
{
var today = nowUtc.Date;
var through = UsageDaysThrough(today, yesterdayAggregated);
var start = through.AddDays(-(days - 1));
var window = new Window(days, start, through, start.AddDays(-days));
var rowsFrom = RowsFrom(today);
var facts = For(SignedUp(configs), stats, rows, through, rowsFrom);
var signedUpIds = new HashSet<string>(facts.Select(f => f.Config.TenantId), StringComparer.OrdinalIgnoreCase);
var countedRows = rows
.Where(r => signedUpIds.Contains(r.TenantId) && (window.Contains(r.Day) || window.ContainsPrevious(r.Day)))
.ToList();
var rowsByTenant = countedRows
.GroupBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
var visitsByTenant = visits?
.GroupBy(v => v.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
var tenants = facts
.Select(f => Tenant(f, window, rowsByTenant, visitsByTenant, today, nowUtc))
.OrderByDescending(t => t.Enrollments)
.ThenByDescending(t => t.LifetimeEnrollments)
.ThenBy(t => t.TenantId, StringComparer.OrdinalIgnoreCase)
.ToList();
return new GetTenantActivityResponse
{
WindowDays = days,
WindowStart = Key(window.Start),
UsageDaysThrough = Key(window.Through),
PreviousWindowStart = Key(window.PreviousStart),
RowsFrom = Key(rowsFrom),
PortalActivityAvailable = visits != null,
Totals = Totals(tenants, visits != null),
Daily = Daily(countedRows.Where(r => window.Contains(r.Day)), window),
Tenants = tenants,
};
}
private static TenantActivityTenantItem Tenant(
TenantLifecycleFacts facts,
Window window,
IReadOnlyDictionary<string, List<TenantActivityDay>> rowsByTenant,
IReadOnlyDictionary<string, List<PortalVisit>>? visitsByTenant,
DateTime today,
DateTime nowUtc)
{
var config = facts.Config;
var tenantRows = rowsByTenant.TryGetValue(config.TenantId, out var found) ? found : NoRows;
var inWindow = tenantRows.Where(r => window.Contains(r.Day)).ToList();
var enrollments = inWindow.Sum(r => r.SessionsTotal);
var previous = tenantRows.Where(r => window.ContainsPrevious(r.Day)).Sum(r => r.SessionsTotal);
var succeeded = inWindow.Sum(r => r.SessionsSucceeded);
var failed = inWindow.Sum(r => r.SessionsFailed);
int[]? daily = null;
if (enrollments > 0)
{
daily = new int[window.Days];
foreach (var row in inWindow)
daily[(int)(row.Day.Date - window.Start).TotalDays] += row.SessionsTotal;
}
int? portalUsers = null;
DateTime? lastVisit = null;
if (visitsByTenant != null)
{
var tenantVisits = visitsByTenant.TryGetValue(config.TenantId, out var v) ? v : NoVisits;
var windowEnd = window.Through.AddDays(1);
portalUsers = tenantVisits
.Where(x => x.At >= window.Start && x.At < windowEnd)
.Select(x => x.PersonKey)
.Distinct(StringComparer.Ordinal)
.Count();
lastVisit = tenantVisits.Count > 0 ? tenantVisits.Max(x => x.At) : null;
}
var counters = facts.Counters;
return new TenantActivityTenantItem
{
TenantId = config.TenantId,
DomainName = config.DomainName,
CompanyName = string.IsNullOrWhiteSpace(config.CompanyName) ? null : config.CompanyName,
Edition = FeatureEntitlementCatalog.Resolve(config, nowUtc).FacetName,
Stage = StageOf(facts, enrollments, previous, window, today),
OnboardedAt = facts.SignupUtc,
Enrollments = enrollments,
EnrollmentsPrevious = previous,
TrendPercent = TrendPercent(enrollments, previous),
Succeeded = succeeded,
Failed = failed,
SuccessPercent = Percent(succeeded, succeeded + failed),
ActiveDays = inWindow.Select(r => r.Day.Date).Distinct().Count(),
DailyEnrollments = daily,
FirstEnrollmentDay = facts.FirstEnrollmentDay is { } first ? Key(first) : null,
LastActiveDay = facts.LastActiveDay is { } last ? Key(last) : null,
LastCounterUpdateAt = counters != null && counters.LastUpdated > DateTime.MinValue ? Utc(counters.LastUpdated) : null,
LifetimeEnrollments = counters?.TotalEnrollments ?? 0,
LifetimeSucceeded = counters?.SuccessfulEnrollments ?? 0,
LifetimeEvents = counters?.TotalEventsProcessed ?? 0,
PortalUsers = portalUsers,
LastPortalVisitAt = lastVisit,
};
}
/// <summary>The first of <see cref="TenantActivityStages.All"/> that applies.</summary>
private static string StageOf(TenantLifecycleFacts facts, int enrollments, int previous, Window window, DateTime today)
{
if (!facts.Enrolled) return TenantActivityStages.NeverEnrolled;
if (facts.IsDormant(today)) return TenantActivityStages.Dormant;
if (facts.FirstEnrollmentPending || facts.FirstEnrollmentDay is { } first && window.Contains(first))
return TenantActivityStages.New;
if (previous >= DecliningMinPrevious && enrollments * 2 <= previous) return TenantActivityStages.Declining;
return enrollments > 0 ? TenantActivityStages.Active : TenantActivityStages.Quiet;
}
private static TenantActivityTotals Totals(IReadOnlyList<TenantActivityTenantItem> tenants, bool portalAvailable)
{
var enrollments = tenants.Sum(t => t.Enrollments);
var previous = tenants.Sum(t => t.EnrollmentsPrevious);
var active = tenants.Count(t => t.Enrollments > 0);
var activePrevious = tenants.Count(t => t.EnrollmentsPrevious > 0);
var succeeded = tenants.Sum(t => t.Succeeded);
var failed = tenants.Sum(t => t.Failed);
int CountStage(string stage) => tenants.Count(t => t.Stage == stage);
return new TenantActivityTotals
{
SignedUpTenants = tenants.Count,
ActiveTenants = active,
ActiveTenantsPrevious = activePrevious,
ActiveTenantsTrendPercent = TrendPercent(active, activePrevious),
Enrollments = enrollments,
EnrollmentsPrevious = previous,
EnrollmentsTrendPercent = TrendPercent(enrollments, previous),
Succeeded = succeeded,
Failed = failed,
SuccessPercent = Percent(succeeded, succeeded + failed),
// The tenants come sorted by enrollments, most first.
Top10SharePercent = Percent(tenants.Take(TopShareTenants).Sum(t => t.Enrollments), enrollments),
Stages = new TenantActivityStageCounts
{
NeverEnrolled = CountStage(TenantActivityStages.NeverEnrolled),
Dormant = CountStage(TenantActivityStages.Dormant),
New = CountStage(TenantActivityStages.New),
Declining = CountStage(TenantActivityStages.Declining),
Active = CountStage(TenantActivityStages.Active),
Quiet = CountStage(TenantActivityStages.Quiet),
},
PortalUsers = portalAvailable ? tenants.Sum(t => t.PortalUsers ?? 0) : null,
PortalTenants = portalAvailable ? tenants.Count(t => t.PortalUsers > 0) : null,
};
}
private static List<TenantActivityDailyItem> Daily(IEnumerable<TenantActivityDay> windowRows, Window window)
{
var byDay = windowRows
.GroupBy(r => r.Day.Date)
.ToDictionary(g => g.Key, g => g.ToList());
var daily = new List<TenantActivityDailyItem>(window.Days);
for (var day = window.Start; day <= window.Through; day = day.AddDays(1))
{
var dayRows = byDay.TryGetValue(day, out var found) ? found : NoRows;
daily.Add(new TenantActivityDailyItem
{
Day = Key(day),
Enrollments = dayRows.Sum(r => r.SessionsTotal),
Succeeded = dayRows.Sum(r => r.SessionsSucceeded),
Failed = dayRows.Sum(r => r.SessionsFailed),
ActiveTenants = dayRows.Select(r => r.TenantId).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
});
}
return daily;
}
/// <summary><paramref name="part"/> of <paramref name="whole"/> in whole percent; null when the whole is zero.</summary>
internal static int? Percent(long part, long whole)
=> whole > 0 ? (int)Math.Round(100.0 * part / whole, MidpointRounding.AwayFromZero) : null;
/// <summary>Change from <paramref name="previous"/> to <paramref name="current"/> in whole percent; null without a previous value.</summary>
internal static int? TrendPercent(int current, int previous)
=> previous > 0 ? (int)Math.Round(100.0 * (current - previous) / previous, MidpointRounding.AwayFromZero) : null;
}
}
@@ -341,5 +341,59 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
}
}
/// <summary>
/// GET /api/global/metrics/mcp-usage?tenantId=&amp;dateFrom=&amp;dateTo= — Global usage
/// </summary>
[Function("GetGlobalMcpUsage")]
public async Task<HttpResponseData> GetGlobalUsage(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage")] HttpRequestData req)
{
_logger.LogInformation("Global MCP usage requested");
try
{
var tenantId = req.Query["tenantId"];
var dateFrom = req.Query["dateFrom"];
var dateTo = req.Query["dateTo"];
var records = await _userUsageRepo.GetUsageByTenantAsync(tenantId ?? "", dateFrom, dateTo);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
}
}
/// <summary>
/// GET /api/global/metrics/mcp-usage/daily?tenantId=&amp;dateFrom=&amp;dateTo= — Daily summaries
/// </summary>
[Function("GetGlobalMcpUsageDaily")]
public async Task<HttpResponseData> GetGlobalUsageDaily(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/daily")] HttpRequestData req)
{
_logger.LogInformation("Global MCP daily usage requested");
try
{
var tenantId = req.Query["tenantId"];
var dateFrom = req.Query["dateFrom"];
var dateTo = req.Query["dateTo"];
var summaries = await _userUsageRepo.GetDailySummaryAsync(tenantId, dateFrom, dateTo);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
}
}
}
}
@@ -1,127 +0,0 @@
using System.Globalization;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// What the growth figures and the tenant activity both know about one signed-up tenant, from its
/// configuration row, its lifetime counters (PlatformStats tenant/current) and the daily UsageMetrics rows.
/// One place, so both answer "enrolled", "last active", "first day with sessions" and "dormant" identically —
/// the GA page and the growth report are checked against each other.
/// </summary>
internal sealed class TenantLifecycleFacts
{
/// <summary>A tenant without activity in this many UTC days, today included, is dormant.</summary>
internal const int ActivityDays = 30;
private static readonly List<DateTime> NoDays = new();
public TenantConfiguration Config { get; init; } = default!;
public TenantStats? Counters { get; init; }
public DateTime? SignupUtc { get; init; }
/// <summary>The lifetime counter holds at least one enrollment.</summary>
public bool Enrolled { get; init; }
/// <summary>The lifetime counter holds at least one Succeeded session.</summary>
public bool Succeeded { get; init; }
/// <summary>
/// Latest UTC day with sessions: the day the counters last moved when that lies after the last daily row
/// (sessions the rows have not seen yet), else the last daily row.
/// </summary>
public DateTime? LastActiveDay { get; init; }
/// <summary>
/// First UTC day with sessions since signup; null while only the counters have seen them (pending) and for
/// a signup before the rows' first day (unknown).
/// </summary>
public DateTime? FirstEnrollmentDay { get; init; }
/// <summary>Enrolled without a daily row since signup, but the counters moved after the last row.</summary>
public bool FirstEnrollmentPending { get; init; }
public int? DaysToFirstEnrollment => FirstEnrollmentDay is { } first && SignupUtc is { } signup
? (int)(first - signup.Date).TotalDays
: null;
/// <summary>Active on <paramref name="day"/> or later.</summary>
public bool ActiveSince(DateTime day) => LastActiveDay >= day;
/// <summary>Enrolled, but no activity in the last <see cref="ActivityDays"/> UTC days, today included.</summary>
public bool IsDormant(DateTime today) => Enrolled && !ActiveSince(ActivityFrom(today));
/// <summary>First day of the dormant look-back: today and the <see cref="ActivityDays"/> − 1 days before.</summary>
internal static DateTime ActivityFrom(DateTime today) => today.AddDays(-(ActivityDays - 1));
/// <summary>
/// The last day the daily rows cover: yesterday once it is aggregated (its platform row exists), else the
/// day before — the first maintenance run after midnight UTC writes yesterday's rows.
/// </summary>
internal static DateTime UsageDaysThrough(DateTime today, bool yesterdayAggregated)
=> today.AddDays(yesterdayAggregated ? -1 : -2);
/// <summary>A configuration row without a domain is a read of an unknown tenant id, not a signup.</summary>
internal static List<TenantConfiguration> SignedUp(IEnumerable<TenantConfiguration> configs)
=> configs.Where(c => !string.IsNullOrEmpty(c.DomainName)).ToList();
/// <summary>
/// The facts of every given tenant. <paramref name="rows"/> are the daily rows from <paramref name="rowsFrom"/>
/// through <paramref name="usageDaysThrough"/>; a first day with sessions before <paramref name="rowsFrom"/>
/// cannot be told, so it stays unknown for an older signup. Tenant ids join case-insensitively.
/// </summary>
internal static List<TenantLifecycleFacts> For(
IEnumerable<TenantConfiguration> configs,
IReadOnlyList<TenantStats> stats,
IReadOnlyList<TenantActivityDay> rows,
DateTime usageDaysThrough,
DateTime rowsFrom)
{
var countersByTenant = stats
.GroupBy(s => s.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var daysByTenant = rows
.GroupBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(r => r.Day.Date).Distinct().OrderBy(d => d).ToList(),
StringComparer.OrdinalIgnoreCase);
return configs
.Select(c => Of(c, countersByTenant, daysByTenant, usageDaysThrough, rowsFrom))
.ToList();
}
private static TenantLifecycleFacts Of(
TenantConfiguration config,
IReadOnlyDictionary<string, TenantStats> countersByTenant,
IReadOnlyDictionary<string, List<DateTime>> daysByTenant,
DateTime usageDaysThrough,
DateTime rowsFrom)
{
DateTime? signup = config.OnboardedAt is { } at ? Utc(at) : null;
countersByTenant.TryGetValue(config.TenantId, out var counters);
var days = daysByTenant.TryGetValue(config.TenantId, out var found) ? found : NoDays;
// The rows end at usageDaysThrough; counters that moved after it saw sessions the rows have not.
DateTime? countersDay = counters != null ? Utc(counters.LastUpdated).Date : null;
var movedAfterRows = countersDay > usageDaysThrough;
DateTime? lastActive = movedAfterRows ? countersDay : days.Count > 0 ? days[^1] : null;
var enrolled = counters?.TotalEnrollments > 0;
var knowable = signup is { } s && s.Date >= rowsFrom;
var firstDay = knowable
? days.Where(d => d >= signup!.Value.Date).Select(d => (DateTime?)d).FirstOrDefault()
: null;
return new TenantLifecycleFacts
{
Config = config,
Counters = counters,
SignupUtc = signup,
Enrolled = enrolled,
Succeeded = counters?.SuccessfulEnrollments > 0,
LastActiveDay = lastActive,
FirstEnrollmentDay = firstDay,
FirstEnrollmentPending = knowable && enrolled && firstDay == null && movedAfterRows,
};
}
internal static DateTime Utc(DateTime value)
=> value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc);
internal static string Key(DateTime day) => day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
}
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules
// keeps a read-only GlobalReader from steering cross-tenant writes).
if (reanalyze && !RecomputeTriggerGate.CanTriggerRecompute(requestCtx, effectiveTenantId))
{
return await RecomputeTriggerGate.RefuseAsync(req, requestCtx, "Your role does not permit triggering re-analysis.");
return await req.ForbiddenAsync("Your role does not permit triggering re-analysis.");
}
// Rules whose StoreRuleResultAsync returned false during the on-demand reanalyze.
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
// gate it to write-capable callers (see RecomputeTriggerGate).
if (rescan && !RecomputeTriggerGate.CanTriggerRecompute(requestCtx, effectiveTenantId))
{
return await RecomputeTriggerGate.RefuseAsync(req, requestCtx, "Your role does not permit triggering a vulnerability re-scan.");
return await req.ForbiddenAsync("Your role does not permit triggering a vulnerability re-scan.");
}
if (rescan)
@@ -1,6 +1,4 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using Microsoft.Azure.Functions.Worker.Http;
namespace AutopilotMonitor.Functions.Helpers;
@@ -18,13 +16,9 @@ public static class RecomputeTriggerGate
/// True when the caller may trigger a recompute for <paramref name="effectiveTenantId"/>:
/// a Global Admin anywhere, or an OWN-TENANT Admin/Operator. Viewer, Global Reader, and
/// delegated (read-tier) callers are excluded, as is any tenant role applied cross-tenant.
/// View as tenant is read-only, and its emulated Admin would pass every other check.
/// </summary>
public static bool CanTriggerRecompute(RequestContext ctx, string effectiveTenantId)
{
if (ctx.ViewAs != null)
return false;
if (ctx.IsGlobalAdmin)
return true;
@@ -32,10 +26,4 @@ public static class RecomputeTriggerGate
return isOwnTenant
&& (ctx.UserRole == Constants.TenantRoles.Admin || ctx.UserRole == Constants.TenantRoles.Operator);
}
/// <summary>The 403 for a refused trigger: the view-as answer, else the role message of the route.</summary>
public static Task<HttpResponseData> RefuseAsync(HttpRequestData req, RequestContext ctx, string roleMessage)
=> ctx.ViewAs != null
? req.ForbiddenAsync(ViewAsTarget.ReadOnlyMessage, Constants.ApiErrorCodes.ViewAsReadOnly)
: req.ForbiddenAsync(roleMessage);
}
@@ -14,11 +14,7 @@ public sealed record RequestContext
{
internal const string ItemsKey = "RequestContext";
/// <summary>
/// The tenant the request acts in: the user's Azure AD tenant ID (JWT tid claim), or the viewed tenant
/// under <see cref="ViewAs"/>. Where the caller's IDENTITY is meant (bindings, presence, attribution),
/// use <see cref="RequestContextExtensions.IdentityTenantId"/>.
/// </summary>
/// <summary>The user's Azure AD tenant ID (from JWT tid claim).</summary>
public string TenantId { get; init; } = string.Empty;
/// <summary>The user's UPN (from JWT upn/preferred_username claim). Display/audit/row-key use — mutable.</summary>
@@ -115,30 +111,13 @@ public sealed record RequestContext
/// Handlers should use this for data access instead of extracting tenantId from route params manually.
/// </summary>
public string TargetTenantId { get; init; } = string.Empty;
/// <summary>
/// Set when a Global Admin views <see cref="TenantId"/> as one of its members (read-only "view as"):
/// every other field then describes that emulated member, while this keeps the real caller's home
/// tenant. Null on every ordinary request.
/// </summary>
public ViewAsContext? ViewAs { get; init; }
}
/// <summary>The real caller behind a view-as request: its home tenant (JWT tid) and the emulated tenant role.</summary>
public sealed record ViewAsContext(string HomeTenantId, string Role);
/// <summary>
/// Extension methods for accessing the resolved RequestContext.
/// </summary>
public static class RequestContextExtensions
{
/// <summary>
/// The tenant the caller's identity is bound to: the JWT tenant, also under view-as. Use it where the
/// caller is identified (SignalR user id, presence, telemetry attribution), never for the data scope.
/// </summary>
public static string IdentityTenantId(this RequestContext ctx)
=> ctx.ViewAs?.HomeTenantId ?? ctx.TenantId;
/// <summary>
/// Gets the resolved RequestContext from FunctionContext.Items.
/// Populated by PolicyEnforcementMiddleware for authenticated requests.
@@ -20,9 +20,4 @@ internal static class RequestRowDimensions
/// <summary>The caller's UPN, or <c>app:&lt;client-id&gt;</c> for an application principal.</summary>
public const string UserId = "UserId";
public const string UserRole = "UserRole";
/// <summary>
/// The tenant a Global Admin viewed as its member (read-only "view as"). Set on the request row and on every
/// item the request emits; TenantId/UserRole of such a row stay the real caller's, never the viewed tenant's.
/// </summary>
public const string ViewAsTenantId = "ViewAsTenantId";
}
@@ -44,11 +44,6 @@ public static class TenantHelper
public static string GetTenantId(HttpRequestData req)
{
// A view-as request acts in the viewed tenant; only PolicyEnforcementMiddleware sets it, for a Global Admin.
var context = req.FunctionContext.GetRequestContext();
if (context.ViewAs != null)
return context.TenantId;
var user = GetAuthenticatedPrincipal(req);
if (user?.Identity?.IsAuthenticated != true)
@@ -1,117 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Reads Microsoft Graph and Entra token-endpoint responses with System.Text.Json (D-420). The members keep
/// the outcomes the former Newtonsoft code had for every realistic body; GraphResponseParsingTests holds the
/// contract, including the deliberate differences.
/// </summary>
internal static class UpstreamJson
{
// Newtonsoft skipped comments and accepted trailing commas; keep both.
private static readonly JsonDocumentOptions ReadOptions = new()
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
};
/// <summary>
/// The body's root object, or null for an empty or blank body and for a JSON null root. Malformed JSON,
/// trailing content and any other root kind throw <see cref="JsonException"/>, which is what the callers'
/// existing catch blocks expect.
/// </summary>
internal static JsonElement? ParseObjectOrNull(string? body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
using var document = JsonDocument.Parse(body, ReadOptions);
var root = document.RootElement;
return root.ValueKind switch
{
JsonValueKind.Object => root.Clone(),
JsonValueKind.Null => null,
_ => throw new JsonException($"Expected a JSON object, got {root.ValueKind}."),
};
}
/// <summary>
/// The property's text; null when it is absent, JSON null, not a string, or text whose escapes are no valid
/// UTF-16. <paramref name="obj"/> must be an object: a list entry of another kind throws
/// <see cref="InvalidOperationException"/>, which the validators turn into a transient result, as before.
/// </summary>
internal static string? GetString(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var value) ? TextOf(value) : null;
// A lone surrogate escape such as \ud800 makes GetString throw; it reads as absent like any other unusable
// value, so one corrupt field cannot turn a whole lookup transient.
private static string? TextOf(JsonElement value)
{
if (value.ValueKind != JsonValueKind.String)
return null;
try
{
return value.GetString();
}
catch (InvalidOperationException)
{
return null;
}
}
/// <summary>The entries of an array property, of any kind; empty when it is absent or not an array.</summary>
internal static IEnumerable<JsonElement> Items(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var array) && array.ValueKind == JsonValueKind.Array
? array.EnumerateArray()
: Enumerable.Empty<JsonElement>();
/// <summary>
/// The page's <c>@odata.nextLink</c>; null when absent or JSON null. Any other value, text that is no valid
/// UTF-16 included, throws <see cref="JsonException"/>, so a scan never ends as complete on a link it cannot
/// follow.
/// </summary>
internal static string? GetNextLink(JsonElement page)
{
if (!page.TryGetProperty("@odata.nextLink", out var link) || link.ValueKind == JsonValueKind.Null)
return null;
return TextOf(link) ?? throw new JsonException($"@odata.nextLink is no readable text ({link.ValueKind}).");
}
/// <summary>A whole number, or a string holding one (Entra v1 and ADFS send expires_in as text); else null.</summary>
internal static long? GetInt64(JsonElement obj, string name)
{
if (!obj.TryGetProperty(name, out var value))
return null;
return value.ValueKind switch
{
JsonValueKind.Number when value.TryGetInt64(out var number) => number,
JsonValueKind.String when long.TryParse(TextOf(value), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed,
_ => null,
};
}
/// <summary>true or false; any other kind, "true" in quotes or 1 included, is null.</summary>
internal static bool? GetBoolean(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
? value.GetBoolean()
: null;
/// <summary>
/// An ISO date string as a UTC instant, read with the invariant culture and full precision; a value
/// without an offset counts as UTC. Null when absent, not a string, blank, unreadable, or Graph's
/// <c>0001-01-01</c> placeholder for an unset date.
/// </summary>
internal static DateTimeOffset? GetUtcDateTime(JsonElement obj, string name)
{
var raw = GetString(obj, name);
if (string.IsNullOrWhiteSpace(raw)
|| !DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed))
return null;
return parsed.Year <= 1 ? null : parsed;
}
}
@@ -92,16 +92,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
principal = cp;
}
var viewAs = ViewAsTarget.FromHeaders(
httpContext.Request.Headers[ViewAsHeaders.Tenant].FirstOrDefault(),
httpContext.Request.Headers[ViewAsHeaders.Role].FirstOrDefault());
// All authorization + RequestContext construction is the transport-agnostic DecideAsync seam.
// Fail-closed on service errors: return 503 (not 500) so clients can retry.
PolicyResult result;
try
{
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext), viewAs);
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext));
}
catch (Exception ex)
{
@@ -117,8 +113,6 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (result.Allowed)
{
context.Items[RequestContext.ItemsKey] = result.Context!;
if (result.Context!.ViewAs != null)
Telemetry.ViewAsTelemetryProcessor.Enter(result.Context.TenantId);
_logger.LogDebug("[PolicyEnforcement] ALLOW {Method} {Path} user={User} role={Role}",
logMethod, logPath, LogSanitizer.Clean(result.UserIdentifier), result.UserRole);
await next(context);
@@ -131,7 +125,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason} oid={Oid} tid={Tid} clientSource={ClientSource} mcpTool={McpTool}",
logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason,
LogSanitizer.Clean(principal?.GetObjectId()), LogSanitizer.Clean(principal?.GetTenantId()), clientSource, mcpToolName);
await ReportPrivilegedDenialAsync(context, result, httpMethod, requestPath, principal, clientSource, mcpToolName, viewAs != null);
await ReportPrivilegedDenialAsync(context, result, httpMethod, requestPath, principal, clientSource, mcpToolName);
// Envelope: the human message is `error`, the policy code (Forbidden, CrossTenantAccessDenied,
// TenantSuspended, McpDisabled, AuthenticationRequired, InsufficientPermissions) is `code`.
await ApiErrorWriter.WriteAsync(
@@ -158,9 +152,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// </summary>
private async Task ReportPrivilegedDenialAsync(
FunctionContext context, PolicyResult result, string httpMethod, string requestPath,
ClaimsPrincipal? principal, string? clientSource, string? mcpToolName, bool viewAsRequested)
ClaimsPrincipal? principal, string? clientSource, string? mcpToolName)
{
if (!IsPrivilegedDenial(result, viewAsRequested) || principal == null)
if (!IsPrivilegedDenial(result) || principal == null)
return;
try
{
@@ -175,17 +169,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
}
}
/// <summary>
/// A 403 by an authenticated caller on a GlobalAdminOnly route — the only denial that is reported. Under
/// view-as the emulated member's denials are the expected picture and stay silent; the report is then the
/// refused view-as itself (someone without Global Admin sending the header).
/// </summary>
internal static bool IsPrivilegedDenial(PolicyResult result, bool viewAsRequested = false)
/// <summary>A 403 by an authenticated caller on a GlobalAdminOnly route — the only denial that is reported.</summary>
internal static bool IsPrivilegedDenial(PolicyResult result)
=> !result.Allowed
&& result.StatusCode == (int)HttpStatusCode.Forbidden
&& (viewAsRequested
? result.ErrorCode == Constants.ApiErrorCodes.ViewAsNotAllowed
: result.Policy == EndpointPolicy.GlobalAdminOnly);
&& result.Policy == EndpointPolicy.GlobalAdminOnly
&& result.StatusCode == (int)HttpStatusCode.Forbidden;
/// <summary>Longest request path kept in a denial record; the route catalog has nothing longer.</summary>
private const int MaxDenialPathLength = 256;
@@ -222,12 +210,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// is unit-testable without faking the worker's HttpContext feature. <see cref="Invoke"/> is a thin
/// adapter that reads the request, calls this, and writes the response.
/// <paramref name="isMcpClient"/> is the self-declared <c>X-Client-Source: mcp</c> marker: it only ever
/// narrows (the tenant MCP switch), never grants. <paramref name="viewAs"/> (the view-as headers) only
/// ever narrows too: a Global Admin is decided as the emulated member of the viewed tenant, read-only.
/// narrows (the tenant MCP switch), never grants.
/// </summary>
internal async Task<PolicyResult> DecideAsync(
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal,
bool isMcpClient = false, ViewAsTarget? viewAs = null)
bool isMcpClient = false)
{
// Sanitized copies for logging only — never used for routing/authorization decisions.
var logPath = LogSanitizer.Clean(requestPath);
@@ -242,23 +229,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "Forbidden", "Access denied.", "anonymous", "N/A", "Unregistered");
}
// View as tenant: admitted first, then decided by the same evaluators over an emulated member of the
// viewed tenant — everything below runs in that tenant (cross-tenant check, suspension gate, context).
// Routes without a JWT tier ignore the headers.
var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry);
var jwtTenantId = principal?.GetTenantId() ?? string.Empty;
ViewAsTarget? viewing = null;
if (viewAs != null && catalogEntry.Policy is not (EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth))
{
var refusal = await AdmitViewAsAsync(viewAs, principal, isMcpClient, catalogEntry);
if (refusal != null)
return refusal;
viewing = viewAs;
}
var actingTenantId = viewing?.TenantId ?? jwtTenantId;
var roles = viewing == null ? CallerRoles.Real(this, principal) : CallerRoles.Emulated(viewing.Role);
var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry, roles, actingTenantId);
var upn = principal?.GetUserPrincipalName();
var isGlobalAdmin = decision.IsAllowed && decision.UserRole == Constants.GlobalRoles.GlobalAdmin;
var isGlobalReader = decision.IsAllowed && decision.UserRole == Constants.GlobalRoles.GlobalReader;
@@ -267,9 +240,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// Resolve the target tenant for tenant-scoped routes ({tenantId} in path, or ?tenantId= query).
// crossTenant = the route names a tenant OTHER than the caller's JWT tenant.
var (targetTenantId, namedTarget) = ResolveTarget(catalogEntry, requestPath, queryTenantId, actingTenantId);
var (targetTenantId, namedTarget) = ResolveTarget(catalogEntry, requestPath, queryTenantId, jwtTenantId);
var crossTenant = namedTarget != null
&& !string.Equals(namedTarget, actingTenantId, StringComparison.OrdinalIgnoreCase);
&& !string.Equals(namedTarget, jwtTenantId, StringComparison.OrdinalIgnoreCase);
// Delegated ("scoped global" / MSP) resolution. A delegated admin reaches a SUBSET of tenants —
// ONLY on tenant-scoped read routes where the named target is in its scope. We resolve lazily:
@@ -283,7 +256,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
{
// Full identity (upn + tid + oid): the scope resolves only for the bound identity, and the home
// tenant (JWT tid) gates it — MSP seats require a Pro home tenant.
var scope = await roles.DelegatedScopeAsync();
var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(principal));
delegatedRole = scope.RoleFor(namedTarget);
if (delegatedRole != null)
allowedTenantIds = scope.TenantIds;
@@ -321,7 +294,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (crossTenant && !hasGlobalScope && !delegatedGrantsRead)
{
_logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access: user={User} jwtTenant={JwtTenant} target={Target} scoping={Scoping} path={Path}",
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(actingTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath);
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(jwtTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath);
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "CrossTenantAccessDenied",
"Access denied. You can only access your own tenant's resources.",
decision.UserIdentifier, decision.UserRole, "CrossTenant", catalogEntry.Policy);
@@ -339,7 +312,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// farewell feedback arrives after their own tenant was tombstoned); any other suspension still holds.
if (principal != null && !hasGlobalScope)
{
var gateConfigs = await LoadGateConfigsAsync(actingTenantId, targetTenantId);
var gateConfigs = await LoadGateConfigsAsync(jwtTenantId, targetTenantId);
var suspended = gateConfigs.FirstOrDefault(c => c.IsCurrentlyDisabled()
&& !(catalogEntry.AllowedDuringOffboarding && TenantOffboardFunction.IsOffboardingTombstone(c)));
if (suspended != null)
@@ -387,9 +360,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
var requestContext = new RequestContext
{
TenantId = actingTenantId,
TenantId = jwtTenantId,
TargetTenantId = targetTenantId,
ViewAs = viewing == null ? null : new ViewAsContext(jwtTenantId, viewing.Role),
// ONLY a genuine JWT UPN lands here — never the "anonymous" placeholder that
// decision.UserIdentifier carries on device/anonymous routes. The placeholder used to be
// published here, which collapsed the entire agent fleet (every tenant) into one shared
@@ -459,40 +431,6 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return admitted;
}
/// <summary>
/// Admission of a view-as request before any policy evaluation: usable headers, a Global Admin in person
/// (an MCP call never; a foreign client app's capped identity holds no platform role), an onboarded
/// tenant, and a route that only reads. Null = admitted.
/// </summary>
private async Task<PolicyResult?> AdmitViewAsAsync(
ViewAsTarget viewAs, ClaimsPrincipal? principal, bool isMcpClient, EndpointPolicyEntry entry)
{
var user = principal?.GetUserPrincipalName() ?? "anonymous";
if (principal == null)
return PolicyResult.Deny((int)HttpStatusCode.Unauthorized, Constants.ApiErrorCodes.AuthenticationRequired,
"Authentication required. Please provide a valid JWT token.", user, "N/A", "NoJWT", entry.Policy);
if (!viewAs.IsValid)
return PolicyResult.Deny((int)HttpStatusCode.BadRequest, Constants.ApiErrorCodes.ViewAsInvalid,
"View as: the tenant must be a tenant ID and the role Admin, Operator or Viewer.", user, "N/A", "ViewAsInvalid", entry.Policy);
var globalRole = isMcpClient ? null : await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != Constants.GlobalRoles.GlobalAdmin)
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, Constants.ApiErrorCodes.ViewAsNotAllowed,
"Viewing a tenant as its member is reserved for Global Admins.", user, globalRole ?? "N/A", "ViewAsNotAllowed", entry.Policy);
var (_, exists) = await _tenantConfigService.TryGetConfigurationAsync(viewAs.TenantId);
if (!exists)
return PolicyResult.Deny((int)HttpStatusCode.NotFound, Constants.ApiErrorCodes.ViewAsTenantNotFound,
"View as: no onboarded tenant has this ID.", user, globalRole, "ViewAsTenantNotFound", entry.Policy);
if (entry.ViewAsAccess == ViewAsAccess.Write)
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, Constants.ApiErrorCodes.ViewAsReadOnly,
ViewAsTarget.ReadOnlyMessage, user, globalRole, "ViewAsReadOnly", entry.Policy);
return null;
}
/// <summary>
/// Resolves the request's target tenant. Returns the effective TargetTenantId (defaults to the JWT
/// tenant) and the explicitly-named tenant from the route/query (null when the route does not name one,
@@ -541,13 +479,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// as a delegated read tier so RoleFor(target) can rescue the cross-tenant block.
or EndpointPolicy.GlobalReadOrDelegatedSubset;
private static async Task<CatalogDecisionResult> EvaluateCatalogPolicyAsync(
ClaimsPrincipal? principal, EndpointPolicyEntry entry, CallerRoles roles, string actingTenantId)
private async Task<CatalogDecisionResult> EvaluateCatalogPolicyAsync(
ClaimsPrincipal? principal, EndpointPolicyEntry entry)
{
// principal is the authenticated ClaimsPrincipal (set by AuthenticationMiddleware), or null on
// anonymous/device routes — resolved by the caller (Invoke / DecideAsync). Every role below comes
// from `roles`, the tenant from actingTenantId (the JWT tenant, or the viewed one under view-as).
var tenantId = principal == null ? null : actingTenantId;
// anonymous/device routes — resolved by the caller (Invoke / DecideAsync).
var tenantId = principal?.GetTenantId();
var upn = principal?.GetUserPrincipalName();
var userIdentifier = upn ?? "anonymous";
@@ -573,31 +510,31 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT");
case EndpointPolicy.AuthenticatedUserWithRole:
return await EvaluateAuthenticatedUserWithRoleAsync(tenantId, upn, principal, roles, userIdentifier);
return await EvaluateAuthenticatedUserWithRoleAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.MemberRead:
return await EvaluateMemberReadAsync(tenantId, upn, roles, userIdentifier);
return await EvaluateMemberReadAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrGlobalReader:
return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, roles, userIdentifier);
return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrOperator:
return await EvaluateTenantAdminOrOperatorAsync(tenantId, upn, roles, userIdentifier);
return await EvaluateTenantAdminOrOperatorAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrGA:
return await EvaluateTenantAdminOrGAAsync(tenantId, upn, roles, userIdentifier);
return await EvaluateTenantAdminOrGAAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.BootstrapManagerOrGA:
return await EvaluateBootstrapManagerOrGAAsync(tenantId, upn, roles, userIdentifier);
return await EvaluateBootstrapManagerOrGAAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.GlobalReadOrAdmin:
return await EvaluateGlobalReadOrAdminAsync(upn, roles, userIdentifier);
return await EvaluateGlobalReadOrAdminAsync(principal, upn, userIdentifier);
case EndpointPolicy.GlobalReadOrDelegatedSubset:
return await EvaluateGlobalReadOrDelegatedSubsetAsync(upn, roles, userIdentifier);
return await EvaluateGlobalReadOrDelegatedSubsetAsync(principal, upn, userIdentifier);
case EndpointPolicy.GlobalAdminOnly:
return await EvaluateGlobalAdminOnlyAsync(upn, roles, userIdentifier);
return await EvaluateGlobalAdminOnlyAsync(principal, upn, userIdentifier);
default:
return CatalogDecisionResult.Deny(userIdentifier, "N/A", $"UnknownPolicy:{entry.Policy}");
@@ -611,8 +548,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// with no special role is admitted as "Authenticated" (role flags stay false) — non-member end
/// users are intentionally allowed here; the function gates the privileged resources.
/// </summary>
private static async Task<CatalogDecisionResult> EvaluateAuthenticatedUserWithRoleAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, CallerRoles roles, string userIdentifier)
private async Task<CatalogDecisionResult> EvaluateAuthenticatedUserWithRoleAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (principal == null)
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT");
@@ -624,11 +561,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// (admin notification group, admin-audience notifications).
if (!string.IsNullOrEmpty(upn))
{
var globalRole = await roles.GlobalRoleAsync();
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != null)
{
var tenantRole = !string.IsNullOrEmpty(tenantId)
? (await roles.TenantRoleAsync(tenantId, upn))?.Role
? (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role
: null;
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "ValidJWT+GlobalScope", tenantRole);
}
@@ -639,7 +576,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// the request is allowed at the middleware tier.
if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(upn))
{
var role = await roles.TenantRoleAsync(tenantId, upn);
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role?.Role != null)
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "ValidJWT+Member", role.Role);
}
@@ -649,8 +586,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Allow(userIdentifier, "Authenticated", "ValidJWT");
}
private static async Task<CatalogDecisionResult> EvaluateMemberReadAsync(
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
private async Task<CatalogDecisionResult> EvaluateMemberReadAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
@@ -658,14 +595,14 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// Any platform role (GlobalAdmin or read-only GlobalReader) satisfies a member-read. ADDITIVE:
// also resolve the own-tenant role and carry it separately so IsTenantAdmin stays correct for a
// GlobalReader who is also their tenant's admin (drives admin-audience on /api/notifications).
var globalRole = await roles.GlobalRoleAsync();
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != null)
{
var tenantRole = (await roles.TenantRoleAsync(tenantId, upn))?.Role;
var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role;
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScopeBypass", tenantRole);
}
var role = await roles.TenantRoleAsync(tenantId, upn);
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role == null)
return CatalogDecisionResult.Deny(userIdentifier, "NonMember", "NotInTenant");
@@ -681,36 +618,36 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// carried, so a GlobalReader who is also their tenant's Admin keeps IsTenantAdmin=true (config GET
/// then serves them the FULL config for their own tenant, redacting only the read-only-reader view).
/// </summary>
private static async Task<CatalogDecisionResult> EvaluateTenantAdminOrGlobalReaderAsync(
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrGlobalReaderAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await roles.GlobalRoleAsync();
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != null)
{
var tenantRole = (await roles.TenantRoleAsync(tenantId, upn))?.Role;
var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role;
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScopeBypass", tenantRole);
}
var role = await roles.TenantRoleAsync(tenantId, upn);
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role?.Role == Constants.TenantRoles.Admin)
return CatalogDecisionResult.Allow(userIdentifier, Constants.TenantRoles.Admin, "TenantAdmin", Constants.TenantRoles.Admin);
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOrGlobalScope");
}
private static async Task<CatalogDecisionResult> EvaluateTenantAdminOrGAAsync(
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrGAAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await roles.IsGlobalAdminAsync())
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await roles.TenantRoleAsync(tenantId, upn);
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role?.Role == Constants.TenantRoles.Admin)
return CatalogDecisionResult.Allow(userIdentifier, Constants.TenantRoles.Admin, "TenantAdmin");
@@ -723,32 +660,32 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// serves multiple operation kinds can re-gate the admin-only ones on IsTenantAdmin.
/// The read-only Global Reader is deliberately NOT admitted — this is a write tier.
/// </summary>
private static async Task<CatalogDecisionResult> EvaluateTenantAdminOrOperatorAsync(
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrOperatorAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await roles.IsGlobalAdminAsync())
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await roles.TenantRoleAsync(tenantId, upn);
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role?.Role == Constants.TenantRoles.Admin || role?.Role == Constants.TenantRoles.Operator)
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "TenantAdminOrOperator");
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOperatorOrGA");
}
private static async Task<CatalogDecisionResult> EvaluateBootstrapManagerOrGAAsync(
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
private async Task<CatalogDecisionResult> EvaluateBootstrapManagerOrGAAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await roles.IsGlobalAdminAsync())
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await roles.TenantRoleAsync(tenantId, upn);
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
var canManageBootstrap = role != null &&
(role.Role == Constants.TenantRoles.Admin ||
(role.Role == Constants.TenantRoles.Operator && role.CanManageBootstrapTokens));
@@ -760,69 +697,26 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
}
/// <summary>
/// The role lookups a policy decision reads. An ordinary request resolves them from the platform, member
/// and delegation tables; a view-as request answers "no platform role, no delegated scope, member role R
/// of the viewed tenant". The evaluators take their roles only from here, so one rule set decides both.
/// Effective tenant member role (table first, Entra app-role claim as the gated fallback) — see
/// <see cref="TenantMemberRoleResolver"/>, which the MCP access check shares so "member" means the same
/// thing on every path.
/// </summary>
private sealed class CallerRoles
{
private readonly PolicyEnforcementMiddleware? _owner;
private readonly ClaimsPrincipal? _principal;
private readonly MemberRoleInfo? _emulated;
private CallerRoles(PolicyEnforcementMiddleware? owner, ClaimsPrincipal? principal, MemberRoleInfo? emulated)
{
_owner = owner;
_principal = principal;
_emulated = emulated;
}
public static CallerRoles Real(PolicyEnforcementMiddleware owner, ClaimsPrincipal? principal)
=> new(owner, principal, null);
/// <summary>A member with this tenant role and nothing else — no bootstrap-token right, no platform role.</summary>
public static CallerRoles Emulated(string tenantRole)
=> new(null, null, new MemberRoleInfo { Role = tenantRole, CanManageBootstrapTokens = false });
public Task<string?> GlobalRoleAsync()
=> _emulated != null
? Task.FromResult<string?>(null)
: _owner!._globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(_principal));
public Task<bool> IsGlobalAdminAsync()
=> _emulated != null
? Task.FromResult(false)
: _owner!._globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(_principal));
/// <summary>
/// Effective tenant member role (table first, Entra app-role claim as the gated fallback) — see
/// <see cref="TenantMemberRoleResolver"/>, which the MCP access check shares so "member" means the same
/// thing on every path.
/// </summary>
public Task<MemberRoleInfo?> TenantRoleAsync(string tenantId, string upn)
=> _emulated != null
? Task.FromResult<MemberRoleInfo?>(_emulated)
: _owner!._memberRoleResolver.ResolveAsync(tenantId, upn, _principal?.GetAppRoles(), _principal?.IsClientCapped() ?? false);
/// <summary>Full identity (upn + tid + oid): the scope resolves only for the bound identity.</summary>
public Task<DelegatedScope> DelegatedScopeAsync()
=> _emulated != null
? Task.FromResult(DelegatedScope.Empty)
: _owner!._delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(_principal));
}
private Task<MemberRoleInfo?> ResolveEffectiveRoleAsync(
string tenantId, string upn, ClaimsPrincipal? principal)
=> _memberRoleResolver.ResolveAsync(tenantId, upn, principal?.GetAppRoles(), principal?.IsClientCapped() ?? false);
/// <summary>
/// Platform-wide cross-tenant READ: admits GlobalAdmin and the read-only GlobalReader. The
/// resolved role string flows onto RequestContext so the function still distinguishes GA from
/// Reader (e.g. for any in-function write gate).
/// </summary>
private static async Task<CatalogDecisionResult> EvaluateGlobalReadOrAdminAsync(
string? upn, CallerRoles roles, string userIdentifier)
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrAdminAsync(
ClaimsPrincipal? principal, string? upn, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await roles.GlobalRoleAsync();
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != null)
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
@@ -837,19 +731,19 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// response to that set. A pure delegated reader of nothing, or any non-global non-delegated caller, is
/// denied. Used by aggregate endpoints whose body can be filtered per tenant (e.g. config/all).
/// </summary>
private static async Task<CatalogDecisionResult> EvaluateGlobalReadOrDelegatedSubsetAsync(
string? upn, CallerRoles roles, string userIdentifier)
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrDelegatedSubsetAsync(
ClaimsPrincipal? principal, string? upn, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await roles.GlobalRoleAsync();
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != null)
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
// Full identity (upn + tid + oid): the scope resolves only for the bound identity, and the home
// tenant (JWT tid) gates it — MSP seats require a Pro home tenant.
var scope = await roles.DelegatedScopeAsync();
var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(principal));
if (!scope.IsEmpty)
return CatalogDecisionResult.Allow(
userIdentifier, Constants.DelegatedRoles.DelegatedReader, "DelegatedSubset",
@@ -858,13 +752,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Deny(userIdentifier, "NonGlobal", "NotGlobalOrDelegated");
}
private static async Task<CatalogDecisionResult> EvaluateGlobalAdminOnlyAsync(
string? upn, CallerRoles roles, string userIdentifier)
private async Task<CatalogDecisionResult> EvaluateGlobalAdminOnlyAsync(
ClaimsPrincipal? principal, string? upn, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await roles.IsGlobalAdminAsync())
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
return CatalogDecisionResult.Allow(userIdentifier, Constants.GlobalRoles.GlobalAdmin, "IsGA");
return CatalogDecisionResult.Deny(userIdentifier, "NonGA", "NotGlobalAdmin");
@@ -7,7 +7,6 @@ using Microsoft.Azure.Functions.Worker.Middleware;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Middleware;
@@ -76,26 +75,6 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
return null;
}
/// <summary>
/// The caller dimensions of the request row. A view-as request counts at the real caller — home tenant
/// (<paramref name="tenantId"/>, resolved from <see cref="RequestContextExtensions.IdentityTenantId"/>) and
/// Global Admin — and names the viewed tenant separately: activity, growth and usage figures read this row.
/// </summary>
internal static void StampCaller(IDictionary<string, string> properties, RequestContext ctx, string? tenantId)
{
if (!string.IsNullOrEmpty(tenantId))
properties[RequestRowDimensions.TenantId] = tenantId;
if (!string.IsNullOrEmpty(ctx.UserPrincipalName))
properties[RequestRowDimensions.UserId] = ctx.UserPrincipalName;
if (ctx.ViewAs != null)
{
properties[RequestRowDimensions.UserRole] = Constants.GlobalRoles.GlobalAdmin;
properties[RequestRowDimensions.ViewAsTenantId] = ctx.TenantId;
}
else if (!string.IsNullOrEmpty(ctx.UserRole))
properties[RequestRowDimensions.UserRole] = ctx.UserRole;
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
var httpContext = context.GetHttpContext();
@@ -233,11 +212,16 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
var reqCtx = context.GetRequestContext();
var tenantId = ResolveTenantId(
reqCtx.IdentityTenantId(),
reqCtx.TenantId,
context.Items,
httpContext.Request.Path.Value ?? string.Empty,
httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault());
StampCaller(requestTelemetry.Properties, reqCtx, tenantId);
if (!string.IsNullOrEmpty(tenantId))
requestTelemetry.Properties[RequestRowDimensions.TenantId] = tenantId;
if (!string.IsNullOrEmpty(reqCtx.UserPrincipalName))
requestTelemetry.Properties[RequestRowDimensions.UserId] = reqCtx.UserPrincipalName;
if (!string.IsNullOrEmpty(reqCtx.UserRole))
requestTelemetry.Properties[RequestRowDimensions.UserRole] = reqCtx.UserRole;
if (caughtException != null)
requestTelemetry.Properties["ExceptionType"] = caughtException.GetType().Name;
@@ -36,7 +36,7 @@ public class UserPresenceMiddleware : IFunctionsWorkerMiddleware
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
var ctx = context.GetRequestContext();
if (Records(ctx))
if (!string.IsNullOrEmpty(ctx.UserPrincipalName) && !string.IsNullOrEmpty(ctx.TenantId))
{
try
{
@@ -56,13 +56,6 @@ public class UserPresenceMiddleware : IFunctionsWorkerMiddleware
await next(context);
}
/// <summary>
/// Whether the request records presence: a signed-in person acting in their own tenant. A Global Admin
/// viewing a tenant as its member (read-only "view as") is not present in that tenant.
/// </summary>
internal static bool Records(RequestContext ctx)
=> ctx.ViewAs == null && !string.IsNullOrEmpty(ctx.UserPrincipalName) && !string.IsNullOrEmpty(ctx.TenantId);
/// <summary>
/// Returns true at most once per <see cref="ThrottleWindow"/> per user (per process), and stamps
/// the write time when it does. Keeps the hot path from upserting on every single request.
@@ -549,8 +549,6 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.IPushSess
// Push endpoints are per-device send capabilities: the dependency rows for the push services
// keep scheme + host only (K20).
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.PushDependencyRedactionProcessor>();
// Items a Global Admin's read-only "view as" request emits carry the viewed tenant as ViewAsTenantId.
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.ViewAsTelemetryProcessor>();
var app = builder.Build();
@@ -7,8 +7,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
@@ -185,8 +185,27 @@ namespace AutopilotMonitor.Functions.Security
};
}
var registration = FindRegistration(responseBody, normalizedSerial);
if (registration == null)
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
var devices = data?["value"] as JArray;
if (devices == null || devices.Count == 0)
{
// Definitive: device not found — cache negative result
return CacheAndReturn(cacheKey, new AutopilotDeviceValidationResult
{
IsValid = false,
SerialNumber = normalizedSerial,
ErrorMessage = $"Device with serial '{normalizedSerial}' is not registered in Autopilot"
}, isPositive: false);
}
// Exact-match guard to avoid false positives from contains(...)
var exactDevice = devices
.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(),
normalizedSerial,
StringComparison.OrdinalIgnoreCase));
if (exactDevice == null)
{
// Definitive: device not found — cache negative result
return CacheAndReturn(cacheKey, new AutopilotDeviceValidationResult
@@ -201,7 +220,7 @@ namespace AutopilotMonitor.Functions.Security
{
IsValid = true,
SerialNumber = normalizedSerial,
Registration = registration
Registration = ReadRegistration(exactDevice)
};
_logger.LogInformation(
@@ -253,25 +272,6 @@ namespace AutopilotMonitor.Functions.Security
return $"autopilot-device-validation:{tenantId}:{serialNumber}";
}
/// <summary>
/// Reads a windowsAutopilotDeviceIdentities list: the registration of the device whose serial
/// equals <paramref name="normalizedSerial"/> exactly (the $filter only narrows with contains),
/// or null when there is none. A body that is not a JSON object, or a non-object entry met
/// before the match, throws; the caller treats that as transient.
/// </summary>
internal static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial)
{
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } data)
return null;
var exactDevice = UpstreamJson.Items(data, "value")
.FirstOrDefault(d => string.Equals(
UpstreamJson.GetString(d, "serialNumber")?.Trim(),
normalizedSerial,
StringComparison.OrdinalIgnoreCase));
return exactDevice.ValueKind == JsonValueKind.Undefined ? null : ReadRegistration(exactDevice);
}
/// <summary>Longest registration text a session keeps; anything beyond is cut.</summary>
internal const int MaxRegistrationFieldLength = 256;
@@ -280,18 +280,18 @@ namespace AutopilotMonitor.Functions.Security
/// assigned user's UPN is only tested for presence and never leaves this method; productKey is
/// never read.
/// </summary>
internal static AutopilotRegistration ReadRegistration(JsonElement device) => new AutopilotRegistration
internal static AutopilotRegistration ReadRegistration(JToken device) => new AutopilotRegistration
{
DeviceId = RegistrationText(device, "id"),
GroupTag = RegistrationText(device, "groupTag"),
PurchaseOrderId = RegistrationText(device, "purchaseOrderIdentifier"),
DisplayName = RegistrationText(device, "displayName"),
UserAssigned = RegistrationText(device, "userPrincipalName") != null,
DeviceId = RegistrationText(device["id"]),
GroupTag = RegistrationText(device["groupTag"]),
PurchaseOrderId = RegistrationText(device["purchaseOrderIdentifier"]),
DisplayName = RegistrationText(device["displayName"]),
UserAssigned = RegistrationText(device["userPrincipalName"]) != null,
};
private static string? RegistrationText(JsonElement device, string name)
private static string? RegistrationText(JToken? token)
{
var value = UpstreamJson.GetString(device, name)?.Trim();
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
if (string.IsNullOrEmpty(value))
return null;
return value!.Length > MaxRegistrationFieldLength ? value.Substring(0, MaxRegistrationFieldLength) : value;
@@ -6,8 +6,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
@@ -245,34 +245,35 @@ namespace AutopilotMonitor.Functions.Security
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
};
JsonElement? data;
JObject? data;
try
{
data = UpstreamJson.ParseObjectOrNull(responseBody);
data = JsonConvert.DeserializeObject<JObject>(responseBody);
}
catch (JsonException)
{
return notFound;
}
if (data is not { } json)
var cloudPcs = data?["value"] as JArray;
if (cloudPcs == null || cloudPcs.Count == 0)
return notFound;
var match = UpstreamJson.Items(json, "value").FirstOrDefault(c => string.Equals(
UpstreamJson.GetString(c, "managedDeviceId")?.Trim(),
var match = cloudPcs.FirstOrDefault(c => string.Equals(
c?["managedDeviceId"]?.ToString()?.Trim(),
normalizedId,
StringComparison.OrdinalIgnoreCase));
if (match.ValueKind == JsonValueKind.Undefined)
if (match == null)
return notFound;
return new CloudPcValidationResult
{
IsValid = true,
IntuneDeviceId = normalizedId,
CloudPcId = UpstreamJson.GetString(match, "id"),
ManagedDeviceName = UpstreamJson.GetString(match, "managedDeviceName"),
ServicePlanName = UpstreamJson.GetString(match, "servicePlanName")
CloudPcId = match["id"]?.ToString(),
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
ServicePlanName = match["servicePlanName"]?.ToString()
};
}
@@ -7,7 +7,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using AutopilotMonitor.Functions.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
@@ -300,11 +301,24 @@ namespace AutopilotMonitor.Functions.Security
};
}
var (found, nextUrl) = ReadIdentityPage(responseBody, normalizedIdentifier);
if (found)
return new ScanResult(ScanOutcome.Found);
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
var identities = data?["value"] as JArray;
if (identities != null)
{
foreach (var identity in identities)
{
var type = identity?["importedDeviceIdentityType"]?.ToString();
var value = identity?["importedDeviceIdentifier"]?.ToString();
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
&& value != null
&& string.Equals(NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
{
return new ScanResult(ScanOutcome.Found);
}
}
}
url = nextUrl;
url = data?["@odata.nextLink"]?.ToString() ?? string.Empty;
}
if (!string.IsNullOrEmpty(url))
@@ -320,32 +334,6 @@ namespace AutopilotMonitor.Functions.Security
return new ScanResult(ScanOutcome.NotFound);
}
/// <summary>
/// Reads one importedDeviceIdentities page: whether a manufacturerModelSerial identity on it
/// normalizes to <paramref name="normalizedIdentifier"/>, and otherwise the next page's url
/// (empty when the scan ends). A body that is not a JSON object, or a non-object entry met
/// before the match, throws; the caller treats that as transient.
/// </summary>
internal static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier)
{
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } data)
return (false, string.Empty);
foreach (var identity in UpstreamJson.Items(data, "value"))
{
var type = UpstreamJson.GetString(identity, "importedDeviceIdentityType");
var value = UpstreamJson.GetString(identity, "importedDeviceIdentifier");
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
&& value != null
&& string.Equals(NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
{
return (true, string.Empty);
}
}
return (false, UpstreamJson.GetNextLink(data) ?? string.Empty);
}
/// <summary>
/// Normalizes one identifier component the way the Intune portal does on upload:
/// uppercase, every non-alphanumeric character removed ("Microsoft Corporation" →
@@ -7,8 +7,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
@@ -236,42 +236,56 @@ namespace AutopilotMonitor.Functions.Security
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
};
JsonElement? data;
JObject? data;
try
{
data = UpstreamJson.ParseObjectOrNull(responseBody);
data = JsonConvert.DeserializeObject<JObject>(responseBody);
}
catch (JsonException)
{
return notFound;
}
if (data is not { } json)
var devices = data?["value"] as JArray;
if (devices == null || devices.Count == 0)
return notFound;
var match = UpstreamJson.Items(json, "value").FirstOrDefault(d => string.Equals(
UpstreamJson.GetString(d, "serialNumber")?.Trim(),
var match = devices.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(),
normalizedSerial,
StringComparison.OrdinalIgnoreCase));
if (match.ValueKind == JsonValueKind.Undefined)
if (match == null)
return notFound;
return new DeviceAssociationResult
{
IsValid = true,
SerialNumber = normalizedSerial,
AssociationState = UpstreamJson.GetString(match, "associationState"),
DevicePreparationPolicyId = UpstreamJson.GetString(match, "devicePreparationPolicyId"),
PreAssociatedByUserPrincipalName = UpstreamJson.GetString(match, "preassociatedByUserPrincipalName"),
AssignedToUserPrincipalName = UpstreamJson.GetString(match, "assignedToUserPrincipalName"),
// Graph returns "0001-01-01T00:00:00Z" for unset dates; GetUtcDateTime reads it as null.
PreAssociationDateTime = UpstreamJson.GetUtcDateTime(match, "preassociationDateTime")?.UtcDateTime,
AssociationDateTime = UpstreamJson.GetUtcDateTime(match, "associationDateTime")?.UtcDateTime,
ManagedDeviceId = UpstreamJson.GetString(match, "managedDeviceId")
AssociationState = match["associationState"]?.ToString(),
DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(),
PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(),
AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(),
PreAssociationDateTime = ParseDateTime(match["preassociationDateTime"]),
AssociationDateTime = ParseDateTime(match["associationDateTime"]),
ManagedDeviceId = match["managedDeviceId"]?.ToString()
};
}
private static DateTime? ParseDateTime(JToken? token)
{
if (token == null || token.Type == JTokenType.Null) return null;
var s = token.ToString();
if (string.IsNullOrEmpty(s)) return null;
// Graph returns "0001-01-01T00:00:00Z" for unset DateTimeOffset fields — treat as null.
if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
{
if (dt.Year <= 1) return null;
return dt;
}
return null;
}
internal static string BuildCacheKey(string tenantId, string serialNumber)
=> $"device-association:{tenantId}:{serialNumber}";
@@ -107,18 +107,6 @@ public enum EndpointPolicy
GlobalAdminOnly,
}
/// <summary>
/// Whether a route may run while a Global Admin views a tenant as its member (read-only "view as").
/// </summary>
public enum ViewAsAccess
{
/// <summary>Reads only (or writes nothing but the caller's own per-user marker); runs in view-as mode.</summary>
Read,
/// <summary>Changes state or starts an action; refused in view-as mode with ViewAsReadOnly.</summary>
Write,
}
/// <summary>
/// A single entry in the endpoint access policy catalog.
/// </summary>
@@ -162,19 +150,12 @@ public sealed class EndpointPolicyEntry
/// </summary>
public bool AllowedDuringOffboarding { get; }
/// <summary>
/// Read or write for view-as mode. Defaults from the method — GET reads, everything else writes, so an
/// unclassified route fails closed. Overridden only with a reason (pinned by ViewAsAccessCatalogTests):
/// a POST that reads, or a GET that starts an action.
/// </summary>
public ViewAsAccess ViewAsAccess { get; }
// Pre-compiled regex for matching actual request paths against the route template
internal Regex RouteRegex { get; }
public EndpointPolicyEntry(string httpMethod, string routeTemplate, EndpointPolicy policy,
TenantScoping tenantScoping = TenantScoping.None, bool excludeDelegated = false, bool applicationAllowed = false,
bool allowedDuringOffboarding = false, ViewAsAccess? viewAsAccess = null)
bool allowedDuringOffboarding = false)
{
HttpMethod = httpMethod.ToUpperInvariant();
RouteTemplate = routeTemplate;
@@ -183,7 +164,6 @@ public sealed class EndpointPolicyEntry
ExcludeDelegated = excludeDelegated;
ApplicationAllowed = applicationAllowed;
AllowedDuringOffboarding = allowedDuringOffboarding;
ViewAsAccess = viewAsAccess ?? (HttpMethod == "GET" ? ViewAsAccess.Read : ViewAsAccess.Write);
RouteRegex = BuildRouteRegex(routeTemplate);
}
@@ -256,13 +236,12 @@ public static class EndpointAccessPolicyCatalog
// ── AuthenticatedUser ───────────────────────────────────────────
new("GET", "auth/me", EndpointPolicy.AuthenticatedUser),
// The caller's own per-user marker under the JWT tenant — also in view-as mode, where What's new opens with it.
new("PUT", "auth/me/whats-new-seen", EndpointPolicy.AuthenticatedUser, viewAsAccess: ViewAsAccess.Read),
new("PUT", "auth/me/whats-new-seen", EndpointPolicy.AuthenticatedUser),
// Terms + DPA acceptance: writes only the caller's own home tenant row (token tid), and only
// while that row asks for it — the portal dialog of a tenant onboarded without the signup tick.
new("POST", "auth/dpa-acceptance", EndpointPolicy.AuthenticatedUser),
new("GET", "auth/is-global-admin", EndpointPolicy.AuthenticatedUser),
new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser, viewAsAccess: ViewAsAccess.Read),
new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser),
// Group join/leave is AuthenticatedUserWithRole — NOT MemberRead — on purpose. The real,
// per-group authorization lives inside SignalRAddToGroup/RemoveFromGroupFunction: it
// requires the requested group's tenant to match the caller's JWT tenant (platform-scope
@@ -281,8 +260,8 @@ public static class EndpointAccessPolicyCatalog
// still resolves IsGlobalAdmin/IsTenantAdmin/UserRole — the function's per-group gates
// depend on those flags; plain AuthenticatedUser leaves them always-false and would break
// GA/admin group joins. (Regression history: c4dabeee elevated these to MemberRead.)
new("POST", "realtime/groups/join", EndpointPolicy.AuthenticatedUserWithRole, viewAsAccess: ViewAsAccess.Read),
new("POST", "realtime/groups/leave", EndpointPolicy.AuthenticatedUserWithRole, viewAsAccess: ViewAsAccess.Read),
new("POST", "realtime/groups/join", EndpointPolicy.AuthenticatedUserWithRole),
new("POST", "realtime/groups/leave", EndpointPolicy.AuthenticatedUserWithRole),
// Progress Portal authorization model: serial-number knowledge proof, enforced in-function.
// There is deliberately NO tenant-wide session list at this tier — the lookup resolves at
// most ONE session server-side (roleless callers: exact serial/device-name match; members/GA
@@ -372,7 +351,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "audit/device-not-registered", EndpointPolicy.MemberRead),
new("GET", "audit/tpm-pss-unsupported", EndpointPolicy.MemberRead),
new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam, viewAsAccess: ViewAsAccess.Read),
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
// Platform-defined diagnostics collection (built-in catalog + global paths): read-only,
// no secrets, needed by every settings viewer — member tier, JWT-scoped (no tenant parameter).
new("GET", "diagnostics/paths", EndpointPolicy.MemberRead, TenantScoping.Jwt),
@@ -383,10 +362,10 @@ public static class EndpointAccessPolicyCatalog
// against a session's events and persists NOTHING (DryRunAnalyzeRuleFunction). Admin-read
// tier so the read-only Global Reader can help debug rules while tenant Operators/Viewers
// stay excluded from the authoring surface.
new("POST", "rules/analyze/dryrun", EndpointPolicy.TenantAdminOrGlobalReader, viewAsAccess: ViewAsAccess.Read),
new("POST", "rules/analyze/dryrun", EndpointPolicy.TenantAdminOrGlobalReader),
// Same shape: POST body in, pure compute out (regex vs pasted sample lines with the
// agent's exact .NET matching semantics) — touches no tenant data at all.
new("POST", "rules/gather/test-pattern", EndpointPolicy.TenantAdminOrGlobalReader, viewAsAccess: ViewAsAccess.Read),
new("POST", "rules/gather/test-pattern", EndpointPolicy.TenantAdminOrGlobalReader),
// Member tier so Operators/Viewers can see their tenant's configuration read-only in the
// Settings UI. Safe because the handler redacts by default: only a Global Admin or the
// tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator,
@@ -439,8 +418,7 @@ public static class EndpointAccessPolicyCatalog
// Farewell feedback is submitted AFTER the offboard endpoint has tombstoned the tenant (Disabled=true
// before the 202), so the suspension gate must let the departing admin through — for this route only.
new("POST", "tenants/{tenantId}/offboard/feedback", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, allowedDuringOffboarding: true),
// Starts the admin-consent flow (ops events with the caller's UPN): an action, although a GET.
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, viewAsAccess: ViewAsAccess.Write),
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("GET", "config/{tenantId}/autopilot-device-validation/consent-status", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/autopilot-device-validation/consent-failure", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/autopilot-device-validation/consent-success", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
@@ -460,10 +438,9 @@ public static class EndpointAccessPolicyCatalog
// expose ClientId and invalidate caches.
// POST (not GET) so payloads with 100+ script refs comfortably fit in the request
// body without hitting URL-length limits in browsers / Azure Functions front door.
new("POST", "tenants/{tenantId}/scripts/display-names", EndpointPolicy.MemberRead, TenantScoping.RouteParam, viewAsAccess: ViewAsAccess.Read),
new("POST", "tenants/{tenantId}/scripts/display-names", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
new("GET", "tenants/{tenantId}/graph-permissions/status", EndpointPolicy.TenantAdminOrGlobalReader, TenantScoping.RouteParam),
// Clears the in-memory Graph cache only — a re-read, nothing stored.
new("POST", "tenants/{tenantId}/graph-permissions/refresh", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, viewAsAccess: ViewAsAccess.Read),
new("POST", "tenants/{tenantId}/graph-permissions/refresh", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
// ── BootstrapManagerOrGA ────────────────────────────────────────
new("GET", "bootstrap/sessions", EndpointPolicy.BootstrapManagerOrGA, TenantScoping.QueryParam),
@@ -589,9 +566,6 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/metrics/verdict-calibration", EndpointPolicy.GlobalReadOrAdmin),
// Operator growth figures across all tenants (signups, activation, plan state); no tenant parameter.
new("GET", "global/metrics/growth", EndpointPolicy.GlobalReadOrAdmin),
// Operator view of every tenant's use (enrollments, lifecycle stage, portal users); no tenant parameter,
// so a delegated (MSP) reader never reaches it.
new("GET", "global/metrics/tenant-activity", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/apps/list", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
@@ -601,9 +575,11 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/metrics/sla", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
// MCP use across tenants: a named tenant's organization budget by account (UPNs) and the all-tenant overview
// with every tenant's accounts and tools. The organization handler reads ?tenantId= itself: deliberately None,
// not QueryParam, so the delegated path never reaches either.
// A named tenant's MCP use — its accounts (UPNs) and their tools, the daily counts, the organization view.
// The handlers read ?tenantId= themselves: deliberately None, not QueryParam, so the delegated path never
// reaches them.
new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/mcp-usage/organization", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/mcp-usage/overview", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin),
@@ -1,7 +1,7 @@
using System;
using System.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
@@ -39,15 +39,16 @@ namespace AutopilotMonitor.Functions.Security
public static EntraTokenRejection Parse(int statusCode, string? body)
{
var json = TryParseObject(body);
var description = json is { } root ? UpstreamJson.GetString(root, "error_description") : null;
var description = (json?["error_description"] as JValue)?.Value as string;
return new EntraTokenRejection(statusCode, ReadCode(json, description), Shorten(description));
}
private static JsonElement? TryParseObject(string? body)
private static JObject? TryParseObject(string? body)
{
if (string.IsNullOrWhiteSpace(body)) return null;
try
{
return UpstreamJson.ParseObjectOrNull(body);
return JsonConvert.DeserializeObject<JToken>(body) as JObject;
}
catch (JsonException)
{
@@ -55,17 +56,10 @@ namespace AutopilotMonitor.Functions.Security
}
}
private static string? ReadCode(JsonElement? json, string? description)
private static string? ReadCode(JObject? json, string? description)
{
if (json is { } root)
{
// The first whole number that fits Int64; text, fractions and larger numbers are skipped.
foreach (var code in UpstreamJson.Items(root, "error_codes"))
{
if (code.ValueKind == JsonValueKind.Number && code.TryGetInt64(out var value))
return CodePrefix + value;
}
}
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
if (first != null) return CodePrefix + first.Value<long>();
// Fallback for a body without error_codes: the description starts with the code.
var start = description?.IndexOf(CodePrefix, StringComparison.Ordinal) ?? -1;
@@ -45,17 +45,6 @@ namespace AutopilotMonitor.Functions.Security
public string SourceName => Source.ToString().ToLowerInvariant();
/// <summary>Wire spelling of <see cref="Edition"/> ("community" | "pro").</summary>
public string EditionName => Edition.ToString().ToLowerInvariant();
/// <summary>
/// The portal's plan facet value (<see cref="TenantEditionNames"/>), by the display precedence of
/// <see cref="Source"/>: Msp "pro-msp", Plan "pro", Trial "pro-trial", else "community".
/// </summary>
public string FacetName => Source switch
{
EditionSource.Msp => TenantEditionNames.ProMsp,
EditionSource.Plan => TenantEditionNames.Pro,
EditionSource.Trial => TenantEditionNames.ProTrial,
_ => TenantEditionNames.Community,
};
}
/// <summary>
@@ -9,7 +9,8 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using AutopilotMonitor.Functions.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
@@ -303,12 +304,13 @@ namespace AutopilotMonitor.Functions.Security
if (response.IsSuccessStatusCode)
{
var (accessToken, expiresInSeconds) = ReadTokenResponse(responseBody);
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
var accessToken = tokenJson?["access_token"]?.ToString();
if (!string.IsNullOrEmpty(accessToken))
{
// Cache the freshly minted token (never log its value). TTL derives from
// the token's own expires_in, minus a skew margin, clamped to a safe band.
CacheToken(tenantId, clientId, accessToken!, genAtStart, expiresInSeconds);
CacheToken(tenantId, clientId, accessToken!, genAtStart, (long?)tokenJson?["expires_in"]);
}
return GraphTokenResult.Minted(accessToken);
}
@@ -466,26 +468,12 @@ namespace AutopilotMonitor.Functions.Security
return false;
}
/// <summary>
/// Reads a successful token response: the access token, and expires_in only when a token came
/// back. A body that is not a JSON object throws <see cref="System.Text.Json.JsonException"/>, which the
/// retry loop does not catch: it leaves the token service like any other unexpected failure.
/// </summary>
internal static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody)
{
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } tokenJson)
return (null, null);
var accessToken = UpstreamJson.GetString(tokenJson, "access_token");
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, UpstreamJson.GetInt64(tokenJson, "expires_in"));
}
private void CacheToken(string tenantId, string clientId, string accessToken, long genAtStart, long? expiresInSeconds)
{
// expires_in is reported by AAD over TLS, so it is trustworthy — but still clamped so a
// missing or pathological value cannot pin a stale token (cap) or cause thrash (floor).
// The seconds are capped before the conversion: a TimeSpan cannot hold the far end of a long.
var lifetime = expiresInSeconds is > 0
? TimeSpan.FromSeconds(Math.Min(expiresInSeconds.Value, (long)(MaxTokenTtl + TokenSkewMargin).TotalSeconds))
? TimeSpan.FromSeconds(expiresInSeconds.Value)
: DefaultTokenLifetime;
var ttl = lifetime - TokenSkewMargin;
if (ttl < MinTokenTtl) ttl = MinTokenTtl;
@@ -8,8 +8,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
@@ -401,42 +401,40 @@ namespace AutopilotMonitor.Functions.Security
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
};
JsonElement? parsed;
JObject? device;
try
{
parsed = UpstreamJson.ParseObjectOrNull(responseBody);
device = JsonConvert.DeserializeObject<JObject>(responseBody);
}
catch (JsonException)
{
return notFound;
}
if (parsed is not { } device)
return notFound;
var returnedId = UpstreamJson.GetString(device, "id")?.Trim();
var returnedId = device?["id"]?.ToString()?.Trim();
if (string.IsNullOrEmpty(returnedId) ||
!string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
{
return notFound;
}
var managementState = UpstreamJson.GetString(device, "managementState");
var managementState = device?["managementState"]?.ToString();
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
return new IntuneDeviceBindingResult
{
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = normalizedId,
DeviceName = UpstreamJson.GetString(device, "deviceName"),
AzureAdDeviceId = UpstreamJson.GetString(device, "azureADDeviceId"),
DeviceName = device?["deviceName"]?.ToString(),
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
ManagementState = managementState,
SerialNumber = NullIfEmpty(UpstreamJson.GetString(device, "serialNumber")),
OwnerType = NullIfEmpty(UpstreamJson.GetString(device, "managedDeviceOwnerType")),
EnrollmentType = NullIfEmpty(UpstreamJson.GetString(device, "deviceEnrollmentType")),
AzureAdRegistered = UpstreamJson.GetBoolean(device, "azureADRegistered"),
// Intune returns "0001-01-01T00:00:00Z" when it has no enrollment timestamp: absence, not a date.
EnrolledDateTime = UpstreamJson.GetUtcDateTime(device, "enrolledDateTime"),
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
? device["azureADRegistered"]!.Value<bool>()
: null,
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
ErrorMessage = leaving
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
: null
@@ -450,6 +448,28 @@ namespace AutopilotMonitor.Functions.Security
internal static bool IsRecentEnrollment(DateTimeOffset? certNotBefore, DateTimeOffset now)
=> certNotBefore.HasValue && now - certNotBefore.Value < RecentEnrollmentWindow;
/// <summary>
/// Parses Graph's <c>enrolledDateTime</c>. Intune returns <c>0001-01-01T00:00:00Z</c> for
/// devices it has no enrollment timestamp for; that is absence, not a date, so it maps to
/// <c>null</c> rather than a bogus age of two thousand years.
/// </summary>
internal static DateTimeOffset? ParseEnrolledDateTime(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (!DateTimeOffset.TryParse(
raw,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal
| System.Globalization.DateTimeStyles.AssumeUniversal,
out var parsed))
{
return null;
}
return parsed.Year <= 1 ? null : parsed;
}
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
internal static string BuildCacheKey(string tenantId, string intuneDeviceId)
@@ -1,56 +0,0 @@
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Security;
/// <summary>
/// "View as tenant": a Global Admin reads one tenant's portal exactly as a member of that tenant sees it,
/// read-only. The portal tab sends these headers on every API call; <c>PolicyEnforcementMiddleware</c>
/// admits the request only for a Global Admin, refuses every write, and builds the request context of the
/// emulated member.
/// </summary>
public static class ViewAsHeaders
{
public const string Tenant = "X-View-As-Tenant";
public const string Role = "X-View-As-Role";
}
/// <summary>A parsed view-as request: the tenant to view (lowercase GUID) and the tenant role to emulate.</summary>
public sealed record ViewAsTarget(string TenantId, string Role)
{
/// <summary>Headers that are present but unusable: not a GUID, or not one of the three tenant roles.</summary>
public static readonly ViewAsTarget Invalid = new(string.Empty, string.Empty);
/// <summary>The one answer to anything that would change state in view-as mode (portal error text).</summary>
public const string ReadOnlyMessage = "Read-only view: changes are disabled while you view this tenant.";
public bool IsValid => TenantId.Length > 0;
/// <summary>
/// Null when no tenant header is sent (an ordinary request); <see cref="Invalid"/> when it is malformed.
/// The role defaults to Admin.
/// </summary>
public static ViewAsTarget? FromHeaders(string? tenantHeader, string? roleHeader)
{
if (string.IsNullOrWhiteSpace(tenantHeader))
return null;
if (!Guid.TryParse(tenantHeader.Trim(), out var tenant) || tenant == Guid.Empty)
return Invalid;
var role = NormalizeRole(roleHeader);
return role == null ? Invalid : new ViewAsTarget(tenant.ToString("D"), role);
}
private static string? NormalizeRole(string? roleHeader)
{
if (string.IsNullOrWhiteSpace(roleHeader))
return Constants.TenantRoles.Admin;
var value = roleHeader.Trim();
foreach (var role in new[] { Constants.TenantRoles.Admin, Constants.TenantRoles.Operator, Constants.TenantRoles.Viewer })
{
if (string.Equals(value, role, StringComparison.OrdinalIgnoreCase))
return role;
}
return null;
}
}
@@ -12,8 +12,7 @@ using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Services.GraphResolution;
@@ -205,10 +204,8 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
{
await WriteCacheEntryAsync(tenantId, r, displayName: null, fileName: null, notFound: true, budget).ConfigureAwait(false);
}
else
else if (displayName != null)
{
// A 200 answers for good even without a name: cached like the full pull caches it, a nameless
// script costs no Graph call on every view until the next full pull.
result[r] = displayName;
await WriteCacheEntryAsync(tenantId, r, displayName, fileName: null, notFound: false, budget).ConfigureAwait(false);
}
@@ -287,9 +284,15 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
throw new HttpRequestException($"Graph LIST {url} returned {(int)status}");
}
var (scripts, nextUrl) = ReadScriptPage(body, withFileName: kind == ScriptKind.Platform);
foreach (var (id, displayName, fileName) in scripts)
var json = JObject.Parse(body);
var values = json["value"] as JArray ?? new JArray();
foreach (var item in values.OfType<JObject>())
{
var id = item["id"]?.ToString();
if (string.IsNullOrEmpty(id)) continue;
var displayName = item["displayName"]?.ToString();
var fileName = kind == ScriptKind.Platform ? item["fileName"]?.ToString() : null;
entries.Add(new ScriptDisplayNameEntry
{
TenantId = tenantId,
@@ -311,7 +314,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
}
}
url = nextUrl;
url = json["@odata.nextLink"]?.ToString();
}
if (entries.Count > 0)
@@ -349,38 +352,10 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
throw new HttpRequestException($"Graph GET {url} returned {(int)status}");
}
return (ReadScriptDisplayName(body), false);
var json = JObject.Parse(body);
return (json["displayName"]?.ToString(), false);
}
/// <summary>
/// Reads one page of a script list: id, display name and (platform scripts only) file name of
/// every object entry with an id, plus the next page's url. A body that is not a JSON object
/// throws, which ends the full pull and falls back to per-ID lookups.
/// </summary>
internal static (List<ScriptListItem> Scripts, string? NextUrl) ReadScriptPage(string body, bool withFileName)
{
var json = ParseScriptObject(body);
var scripts = new List<ScriptListItem>();
foreach (var item in UpstreamJson.Items(json, "value").Where(i => i.ValueKind == JsonValueKind.Object))
{
var id = UpstreamJson.GetString(item, "id");
if (string.IsNullOrEmpty(id)) continue;
scripts.Add(new ScriptListItem(id!, UpstreamJson.GetString(item, "displayName"),
withFileName ? UpstreamJson.GetString(item, "fileName") : null));
}
return (scripts, UpstreamJson.GetNextLink(json));
}
/// <summary>Reads the display name of a single-script GET; a body that is not a JSON object throws.</summary>
internal static string? ReadScriptDisplayName(string body) => UpstreamJson.GetString(ParseScriptObject(body), "displayName");
// An empty or null body must throw like any other unreadable one: the full pull then falls back to
// per-ID lookups instead of recording the list as fetched.
private static JsonElement ParseScriptObject(string body)
=> UpstreamJson.ParseObjectOrNull(body) ?? throw new JsonException("The Graph script response is empty or null.");
internal readonly record struct ScriptListItem(string Id, string? DisplayName, string? FileName);
private async Task WriteCacheEntryAsync(
string tenantId, ScriptRef r, string? displayName, string? fileName, bool notFound, CancellationToken ct)
{
@@ -13,6 +13,7 @@ using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
@@ -1,5 +1,4 @@
using System.Globalization;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -89,47 +88,19 @@ public class McpUsageOverviewService
facts[(account.Tenant, principal)] = new AccountFacts(account.UserId, platformRole, state);
});
// Every tenant the overview names — the per-minute rows included — with its plan and its configuration, read once.
var tenants = counters.Select(r => r.TenantId.ToLowerInvariant())
.Concat(telemetry.Calls.Select(r => r.Tenant))
.Concat(telemetry.Peaks.Select(p => p.Tenant))
.Concat(telemetry.Refusals.Select(r => r.Tenant))
.Where(t => !string.IsNullOrEmpty(t))
.Distinct(StringComparer.Ordinal)
.ToList();
var tenantLimits = new System.Collections.Concurrent.ConcurrentDictionary<string, McpQuotaService.TenantPlanLimits>(StringComparer.Ordinal);
var tenantConfigs = new System.Collections.Concurrent.ConcurrentDictionary<string, TenantConfiguration?>(StringComparer.Ordinal);
await Parallel.ForEachAsync(tenants, new ParallelOptions { MaxDegreeOfParallelism = AccountReadParallelism }, async (tenant, _) =>
{
tenantLimits[tenant] = await _quota.ResolveTenantPlanAsync(tenant);
tenantConfigs[tenant] = await ReadTenantConfigurationAsync(tenant);
});
tenantLimits[tenant] = await _quota.ResolveTenantPlanAsync(tenant));
var minuteLimits = await ResolveMinuteLimitsAsync(telemetry, facts, telemetryRoles, tenantConfigs);
var tenantNames = tenantConfigs
.Where(c => !string.IsNullOrWhiteSpace(c.Value?.DomainName))
.ToDictionary(c => c.Key, c => c.Value!.DomainName, StringComparer.Ordinal);
var minuteLimits = await ResolveMinuteLimitsAsync(telemetry, facts, telemetryRoles);
return Assemble(new OverviewInputs(
dates, includePlatformAdmins, counters, facts, tenantLimits, tenantNames, telemetryRoles, minuteLimits, telemetry));
}
/// <summary>
/// The tenant's configuration (cached) for its name and its per-minute override. A storage failure leaves both
/// out and is logged; it never fails the overview, which is about usage, not configuration.
/// </summary>
internal async Task<TenantConfiguration?> ReadTenantConfigurationAsync(string tenantId)
{
try
{
return await _tenantConfig.GetConfigurationIfExistsAsync(tenantId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[McpUsageOverview] Configuration of tenant {TenantId} unreadable: name and per-minute override left out",
LogSanitizer.Clean(tenantId));
return null;
}
dates, includePlatformAdmins, counters, facts, tenantLimits, telemetryRoles, minuteLimits, telemetry));
}
// ── Telemetry ──────────────────────────────────────────────────────────────────
@@ -199,8 +170,7 @@ public class McpUsageOverviewService
/// </summary>
private async Task<Dictionary<(string Tenant, string Principal), int>> ResolveMinuteLimitsAsync(
TelemetryResult telemetry, IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> facts,
IReadOnlyDictionary<(string Tenant, string Principal), string> telemetryRoles,
IReadOnlyDictionary<string, TenantConfiguration?> tenantConfigs)
IReadOnlyDictionary<(string Tenant, string Principal), string> telemetryRoles)
{
var config = await _adminConfig.GetConfigurationAsync();
var keys = telemetry.Peaks.Select(p => (p.Tenant, p.Principal))
@@ -223,8 +193,9 @@ public class McpUsageOverviewService
{
if (!perTenant.TryGetValue(key.Tenant, out tenantSide))
{
var tenantConfig = await _tenantConfig.GetConfigurationIfExistsAsync(key.Tenant);
var entitlements = await _entitlements.GetEntitlementsAsync(key.Tenant);
tenantSide = (tenantConfigs.GetValueOrDefault(key.Tenant)?.CustomUserRateLimitRequestsPerMinute, entitlements.UserRateLimitPerMinute);
tenantSide = (tenantConfig?.CustomUserRateLimitRequestsPerMinute, entitlements.UserRateLimitPerMinute);
perTenant[key.Tenant] = tenantSide;
}
}
@@ -291,7 +262,6 @@ public class McpUsageOverviewService
IReadOnlyList<TenantUsageRecord> Counters,
IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> Accounts,
IReadOnlyDictionary<string, McpQuotaService.TenantPlanLimits> TenantLimits,
IReadOnlyDictionary<string, string> TenantNames,
IReadOnlyDictionary<(string Tenant, string Principal), string> TelemetryRoles,
IReadOnlyDictionary<(string Tenant, string Principal), int> MinuteLimits,
TelemetryResult Telemetry);
@@ -416,7 +386,6 @@ public class McpUsageOverviewService
return new McpUsageTenantItem
{
TenantId = tenant,
DomainName = input.TenantNames.GetValueOrDefault(tenant),
Quota = McpQuotaNodes.BuildOrganizationQuota(tenantRows, limits, dates.Today, dates.MonthStart),
Calls = members.Sum(a => a.Calls),
Requests = members.Sum(a => a.Requests),
@@ -497,10 +466,6 @@ public class McpUsageOverviewService
.ToDictionary(g => g.Key, g => g.ToList());
var mcpLimit = telemetry.ServerFacts.RateLimitPerMinute;
// The higher of the two shares of a minute limit (0–1+); the list ranks by it and shows it in whole percent.
double MinuteShare(long peakCalls, long peakRequests, int backendLimit) => Math.Max(
mcpLimit is int limit && limit > 0 ? (double)peakCalls / limit : 0,
backendLimit > 0 ? (double)peakRequests / backendLimit : 0);
var minuteItems = peaks.Keys.Concat(mcpRefusals.Keys).Concat(backendRefusals.Keys).Distinct()
.Select(key =>
{
@@ -509,27 +474,25 @@ public class McpUsageOverviewService
var requestPeak = accountPeaks?.FirstOrDefault(p => p.Metric == PeakRequests);
mcpRefusals.TryGetValue(key, out var mcpRefused);
var hasBackendRefusal = backendRefusals.TryGetValue(key, out var backendRefused);
var backendLimit = input.MinuteLimits.GetValueOrDefault(key);
var share = MinuteShare(callPeak?.Peak ?? 0, requestPeak?.Peak ?? 0, backendLimit);
return (Share: share, Item: new McpUsageMinuteItem
return new McpUsageMinuteItem
{
TenantId = key.Tenant,
Principal = key.Principal,
SharePercent = (int)Math.Round(share * 100, MidpointRounding.AwayFromZero),
PeakCalls = callPeak?.Peak ?? 0,
PeakCallsAt = callPeak?.PeakAt,
McpRefused = mcpRefused?.Refused ?? 0,
McpLastRefusedAt = mcpRefused?.LastAt,
PeakRequests = requestPeak?.Peak ?? 0,
PeakRequestsAt = requestPeak?.PeakAt,
BackendLimit = backendLimit,
BackendLimit = input.MinuteLimits.GetValueOrDefault(key),
BackendRefused = hasBackendRefusal ? backendRefused.Refused : 0,
BackendLastRefusedAt = hasBackendRefusal ? backendRefused.LastAt : null,
});
};
})
.OrderByDescending(m => m.Share)
.ThenBy(m => m.Item.Principal, StringComparer.Ordinal)
.Select(m => m.Item)
.OrderByDescending(m => Math.Max(
mcpLimit is int limit && limit > 0 ? (double)m.PeakCalls / limit : 0,
m.BackendLimit > 0 ? (double)m.PeakRequests / m.BackendLimit : 0))
.ThenBy(m => m.Principal, StringComparer.Ordinal)
.ToList();
var mcpLogFrom = telemetry.ServerFacts.Oldest is DateTime oldest
@@ -548,7 +511,6 @@ public class McpUsageOverviewService
McpLogFrom = mcpLogFrom != null && string.CompareOrdinal(mcpLogFrom, dates.From) > 0 ? mcpLogFrom : null,
Partial = telemetry.Partial ? true : null,
},
Totals = BuildTotals(tenants, tools, quotaHits, minuteItems, telemetry),
Daily = daily,
Tenants = tenants,
Tools = tools,
@@ -557,36 +519,5 @@ public class McpUsageOverviewService
};
}
/// <summary>
/// Pure: the totals over the assembled lists. Calls need the request telemetry and MCP-only calls the MCP log —
/// without the store the figure is absent, never a misleading partial sum. An account is a tenant and principal;
/// an organization-level refusal counts for the account that met it.
/// </summary>
private static McpUsageTotalsNode BuildTotals(
IReadOnlyList<McpUsageTenantItem> tenants, IReadOnlyList<McpUsageToolItem> tools,
IReadOnlyList<McpUsageQuotaHitItem> quotaHits, IReadOnlyList<McpUsageMinuteItem> minuteItems, TelemetryResult telemetry)
{
var accounts = tenants.SelectMany(t => t.Accounts).ToList();
var quotaHitAccounts = quotaHits.Select(h => (h.TenantId, Principal: h.Principal ?? string.Empty)).ToHashSet();
var minuteRefusedAccounts = minuteItems
.Where(m => m.McpRefused > 0 || m.BackendRefused > 0)
.Select(m => (m.TenantId, m.Principal))
.ToHashSet();
return new McpUsageTotalsNode
{
Calls = telemetry.RequestsAvailable ? tools.Sum(t => t.Calls) : null,
McpOnlyCalls = telemetry.McpLogAvailable ? tools.Where(t => t.Accounts == null).Sum(t => t.Calls) : null,
Tools = tools.Count,
Requests = tenants.Sum(t => t.Requests),
Tenants = tenants.Count,
Accounts = accounts.Count,
ServicePrincipals = accounts.Count(a => Constants.PrincipalKeys.IsApplication(a.Principal)),
QuotaHitAccounts = quotaHitAccounts.Count,
MinuteRefusedAccounts = minuteRefusedAccounts.Count,
LimitAccounts = quotaHitAccounts.Union(minuteRefusedAccounts).Count(),
};
}
private static DateTime? Later(DateTime? a, DateTime? b) => a == null ? b : b == null ? a : (a > b ? a : b);
}
@@ -73,7 +73,7 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Tenant days with sessions between two UTC days (both inclusive): one UsageMetrics row per
/// tenant and day, the platform row excluded, only the session counts read. Not fail-soft —
/// tenant and day, the platform row excluded, only the session count read. Not fail-soft —
/// a swallowed failure would read as "no tenant was active".
/// </summary>
public async Task<List<TenantActivityDay>> GetTenantActivityDaysAsync(DateTime fromDay, DateTime toDay)
@@ -83,27 +83,16 @@ namespace AutopilotMonitor.Functions.Services
+ $" and RowKey ne '{UsageMetricsPlatformRowKey}'";
var result = new List<TenantActivityDay>();
await foreach (var row in tableClient.QueryAsync<TableEntity>(
filter: filter, select: TenantActivityDayColumns).ConfigureAwait(false))
filter: filter, select: new[] { "PartitionKey", "RowKey", "SessionsTotal" }).ConfigureAwait(false))
{
var sessions = row.GetInt32("SessionsTotal") ?? 0;
if (sessions > 0 && DateTime.TryParseExact(row.PartitionKey, UsageDayFormat, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var day))
result.Add(new TenantActivityDay
{
Day = day,
TenantId = row.RowKey,
SessionsTotal = sessions,
SessionsSucceeded = row.GetInt32("SessionsSucceeded") ?? 0,
SessionsFailed = row.GetInt32("SessionsFailed") ?? 0,
});
result.Add(new TenantActivityDay { Day = day, TenantId = row.RowKey, SessionsTotal = sessions });
}
return result;
}
/// <summary>The columns <see cref="GetTenantActivityDaysAsync"/> reads of a daily UsageMetrics row.</summary>
internal static readonly string[] TenantActivityDayColumns =
{ "PartitionKey", "RowKey", "SessionsTotal", "SessionsSucceeded", "SessionsFailed" };
private const string UsageDayFormat = "yyyy-MM-dd";
private static string UsageDayKey(DateTime day) => day.ToString(UsageDayFormat, CultureInfo.InvariantCulture);
@@ -1622,46 +1611,6 @@ namespace AutopilotMonitor.Functions.Services
return result.Values.ToList();
}
/// <summary>
/// Every portal visit of a person since a point in time — see IMetricsRepository. LoginAt is not a key, so
/// this is one filtered scan over every partition, projected to the four columns it reads; the table keeps
/// 90 days. Not fail-soft: an empty answer would read as "nobody visited".
/// </summary>
public async Task<List<PortalVisit>> GetPortalVisitsSinceAsync(DateTime sinceUtc)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
var filter = $"LoginAt ge datetime'{sinceUtc:yyyy-MM-ddTHH:mm:ss}Z'";
var visits = new List<PortalVisit>();
await foreach (var row in tableClient.QueryAsync<TableEntity>(
filter: filter, select: PortalVisitColumns).ConfigureAwait(false))
{
var upn = row.GetString("Upn");
var person = PortalPersonKey(row.GetString("ObjectId"), upn);
var at = row.GetDateTime("LoginAt");
// A service principal's MCP session is recorded here too (McpUserFunction); it is no person.
if (person == null || at == null || Constants.PrincipalKeys.IsApplication(upn))
continue;
visits.Add(new PortalVisit
{
TenantId = row.PartitionKey.ToLowerInvariant(),
PersonKey = person,
At = DateTime.SpecifyKind(at.Value, DateTimeKind.Utc),
});
}
return visits;
}
/// <summary>The columns <see cref="GetPortalVisitsSinceAsync"/> reads of a UserActivity row.</summary>
internal static readonly string[] PortalVisitColumns = { "PartitionKey", "Upn", "ObjectId", "LoginAt" };
/// <summary>The person behind a sign-in row: its object id, else its UPN, lower-cased; null without either.</summary>
internal static string? PortalPersonKey(string? objectId, string? upn)
{
if (!string.IsNullOrWhiteSpace(objectId))
return objectId.Trim().ToLowerInvariant();
return string.IsNullOrWhiteSpace(upn) ? null : upn.Trim().ToLowerInvariant();
}
/// <summary>
/// Gets user activity metrics for a specific tenant
/// </summary>
@@ -1,36 +0,0 @@
using AutopilotMonitor.Functions.Helpers;
using Microsoft.ApplicationInsights.Channel;
using Microsoft.ApplicationInsights.DataContracts;
using Microsoft.ApplicationInsights.Extensibility;
namespace AutopilotMonitor.Functions.Telemetry;
/// <summary>
/// Stamps every telemetry item a view-as request emits (customEvents, traces, dependencies) with the viewed
/// tenant, so KQL can leave a Global Admin's read-only "view as" out of that tenant's activity. Handlers put
/// the viewed tenant on their own events; this marks them as not the tenant's doing. The request row is
/// stamped by RequestTelemetryMiddleware, which runs outside the scope <see cref="Enter"/> opens.
/// </summary>
public sealed class ViewAsTelemetryProcessor : ITelemetryProcessor
{
private static readonly AsyncLocal<string?> ViewedTenant = new();
private readonly ITelemetryProcessor _next;
public ViewAsTelemetryProcessor(ITelemetryProcessor next) => _next = next;
/// <summary>Marks the rest of the current async flow — one admitted view-as request — as viewing this tenant.</summary>
public static void Enter(string viewedTenantId) => ViewedTenant.Value = viewedTenantId;
public void Process(ITelemetry item)
{
Stamp(item, ViewedTenant.Value);
_next.Process(item);
}
internal static void Stamp(ITelemetry item, string? viewedTenantId)
{
if (viewedTenantId != null && item is ISupportProperties withProperties)
withProperties.Properties.TryAdd(RequestRowDimensions.ViewAsTenantId, viewedTenantId);
}
}
+4 -4
View File
@@ -30,7 +30,7 @@
"@types/express": "^5.0.6",
"@types/node": "^22.20.5",
"eslint": "^9.39.5",
"knip": "^6.44.0",
"knip": "^6.43.0",
"tsx": "^4.23.15",
"typescript": "^5.0.0",
"typescript-eslint": "^8.72.1",
@@ -4351,9 +4351,9 @@
}
},
"node_modules/knip": {
"version": "6.44.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
"integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.43.0.tgz",
"integrity": "sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==",
"dev": true,
"funding": [
{
@@ -40,7 +40,7 @@
"@types/express": "^5.0.6",
"@types/node": "^22.20.5",
"eslint": "^9.39.5",
"knip": "^6.44.0",
"knip": "^6.43.0",
"tsx": "^4.23.15",
"typescript": "^5.0.0",
"typescript-eslint": "^8.72.1",
@@ -1,274 +0,0 @@
/**
* get_api_usage: the shaping of the MCP usage views (api-usage-shape.ts) and the tool's routing.
* - the summary cuts every list to its head and states the full length; nothing is cut silently;
* - absent wire values become explicit nulls (calls without request telemetry, unreadable counters);
* - tenants are named on refusal and minute rows; an account's counter keys fold into tool and day sums;
* - organization and account need their id; a UPN is not an object id.
*
* apiFetch is mocked so these run with no backend / token.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
const { apiFetchMock } = vi.hoisted(() => ({ apiFetchMock: vi.fn() }));
vi.mock('../client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../client.js')>();
return { ...actual, apiFetch: apiFetchMock };
});
import { registerAdminTools } from '../tools/admin.js';
import { ObjectIdSchema } from '../tools/shared.js';
import { NO_TOOL, SUMMARY_HEAD, shapeAccount, shapeOrganization, shapeOverview, usageWindow } from '../api-usage-shape.js';
import type {
GetGlobalMcpUsageOverviewResponse,
GetMcpOrganizationUsageResponse,
GetMcpUserUsageResponse,
McpUsageAccountItem,
McpUsageMinuteItem,
McpUsageQuotaHitItem,
McpUsageTenantItem,
McpUsageToolItem,
} from '../generated/wire-types.generated.js';
const TENANT_A = 'aaaaaaaa-0000-4000-8000-00000000000a';
const TENANT_B = 'bbbbbbbb-0000-4000-8000-00000000000b';
const OID = '11111111-2222-4333-8444-555555555555';
const AT = '2026-11-05T10:00:00Z';
function account(principal: string, over: Partial<McpUsageAccountItem> = {}): McpUsageAccountItem {
return {
userId: OID,
principal,
isPlatformAdmin: false,
quota: { plan: 'community', dailyLimit: 50, monthlyLimit: 250, dailyUsed: 12, monthlyUsed: 40 },
calls: 5,
requests: 12,
quotaHits: 0,
topTools: [{ tool: 'get_session_summary', calls: 3 }],
...over,
};
}
function tenant(tenantId: string, domainName: string | undefined, accounts: McpUsageAccountItem[]): McpUsageTenantItem {
return {
tenantId,
...(domainName ? { domainName } : {}),
quota: { tenantPlan: 'community', dailyLimit: 100, monthlyLimit: 250, dailyUsed: 30, monthlyUsed: 90 },
calls: accounts.reduce((s, a) => s + a.calls, 0),
requests: accounts.reduce((s, a) => s + a.requests, 0),
quotaHits: 1,
accounts,
};
}
const tool = (name: string, calls: number, over: Partial<McpUsageToolItem> = {}): McpUsageToolItem =>
({ tool: name, calls, accounts: 2, tenants: 1, requestsPerCall: 4.5, errorRate: 0.1, p95DurationMs: 900, routes: [{ route: 'GET sessions/{id}', requests: 9 }], ...over });
const hit = (tenantId: string, principal: string): McpUsageQuotaHitItem =>
({ date: '20261105', tenantId, principal, level: 'user', scope: 'daily', plan: 'community', limit: 50, used: 50, refused: 3, firstAt: AT, lastAt: AT });
const minute = (tenantId: string, principal: string, sharePercent: number): McpUsageMinuteItem =>
({ tenantId, principal, sharePercent, peakCalls: 30, peakCallsAt: AT, mcpRefused: 0, peakRequests: 60, peakRequestsAt: AT, backendLimit: 120, backendRefused: 0 });
function overview(over: Partial<GetGlobalMcpUsageOverviewResponse> = {}): GetGlobalMcpUsageOverviewResponse {
return {
dateFrom: '20261007',
dateTo: '20261105',
includesPlatformAdmins: false,
telemetry: { requestsAvailable: true, mcpLogAvailable: true },
totals: {
calls: 40, mcpOnlyCalls: 4, tools: 2, requests: 24, tenants: 2, accounts: 2, servicePrincipals: 0,
quotaHitAccounts: 1, minuteRefusedAccounts: 0, limitAccounts: 1,
},
daily: [{ date: '20261105', calls: 40, requests: 24, accounts: 2, callsByPlan: [{ plan: 'community', calls: 40 }] }],
tenants: [tenant(TENANT_A, 'contoso.com', [account('alice@contoso.com')]), tenant(TENANT_B, undefined, [account('bob@fabrikam.com')])],
tools: [tool('get_session_summary', 36), tool('search_docs', 4, { accounts: undefined, tenants: undefined, requestsPerCall: 0, routes: [] })],
quotaHits: [hit(TENANT_A, 'alice@contoso.com')],
minuteLimits: { mcpServerLimit: 60, accounts: [minute(TENANT_A, 'alice@contoso.com', 50), minute(TENANT_B, 'bob@fabrikam.com', 25)] },
...over,
};
}
describe('usageWindow', () => {
it('is the trailing window of whole UTC days ending today, as yyyyMMdd', () => {
const now = new Date('2026-11-05T23:30:00Z');
expect(usageWindow(1, now)).toEqual({ from: '20261105', to: '20261105' });
expect(usageWindow(30, now)).toEqual({ from: '20261007', to: '20261105' });
});
});
describe('shapeOverview — summary', () => {
it('carries the backend totals and every list head with its full length', () => {
const out = shapeOverview(overview(), 'summary');
expect(out.view).toBe('summary');
expect(out.window).toEqual({ from: '20261007', to: '20261105' });
expect(out.telemetry).toEqual({ requestsAvailable: true, mcpLogAvailable: true, mcpLogFrom: null, partial: false });
expect(out.totals).toMatchObject({ calls: 40, mcpOnlyCalls: 4, requests: 24, limitAccounts: 1 });
expect(out.lists).toEqual({ tools: 2, tenants: 2, quotaHits: 1, minuteLimits: 2 });
expect(out).not.toHaveProperty('note'); // nothing was cut
const [summaryTool, docs] = out.tools as Array<Record<string, unknown>>;
expect(summaryTool).toEqual({ tool: 'get_session_summary', calls: 36, requestsPerCall: 4.5, accounts: 2, tenants: 1, errorRate: 0.1, p95DurationMs: 900 });
expect(docs).toMatchObject({ tool: 'search_docs', accounts: null, tenants: null }); // runs in the MCP server alone
const [contoso, unnamed] = out.tenants as Array<Record<string, unknown>>;
expect(contoso).toMatchObject({ domainName: 'contoso.com', plan: 'community', accounts: 1, today: { used: 30, limit: 100 }, month: { used: 90, limit: 250 } });
expect(unnamed.domainName).toBeNull();
expect((out.quotaHits as Array<Record<string, unknown>>)[0]).toMatchObject({ domainName: 'contoso.com', principal: 'alice@contoso.com', used: 50, limit: 50 });
const minuteLimits = out.minuteLimits as { mcpServerLimit: number; accounts: Array<Record<string, unknown>> };
expect(minuteLimits.mcpServerLimit).toBe(60);
expect(minuteLimits.accounts.map((m) => [m.principal, m.domainName, m.sharePercent])).toEqual([
['alice@contoso.com', 'contoso.com', 50],
['bob@fabrikam.com', null, 25],
]);
});
it('cuts a long list to its head and says so', () => {
const tools = Array.from({ length: SUMMARY_HEAD.tools + 3 }, (_, i) => tool(`tool_${i}`, 100 - i));
const out = shapeOverview(overview({ tools }), 'summary');
expect(out.tools).toHaveLength(SUMMARY_HEAD.tools);
expect((out.lists as Record<string, number>).tools).toBe(SUMMARY_HEAD.tools + 3);
expect(out.note).toMatch(/cut to their first rows/);
});
it('shows calls as null, not 0, when the request telemetry did not answer', () => {
const out = shapeOverview(overview({
telemetry: { requestsAvailable: false, mcpLogAvailable: false, mcpLogFrom: '20261020', partial: true },
totals: { tools: 0, requests: 24, tenants: 2, accounts: 2, servicePrincipals: 0, quotaHitAccounts: 0, minuteRefusedAccounts: 0, limitAccounts: 0 },
}), 'summary');
expect(out.totals).toMatchObject({ calls: null, mcpOnlyCalls: null, requests: 24 });
expect(out.telemetry).toEqual({ requestsAvailable: false, mcpLogAvailable: false, mcpLogFrom: '20261020', partial: true });
});
});
describe('shapeOverview — one list in full', () => {
it('tenants carry every account with explicit windows; an unreadable counter is null', () => {
const unreadable = account('carol@contoso.com', { quota: { plan: 'community', dailyLimit: 50, monthlyLimit: 250, dailyUsed: -1, monthlyUsed: -1 } });
const out = shapeOverview(overview({ tenants: [tenant(TENANT_A, 'contoso.com', [account('alice@contoso.com'), unreadable])] }), 'tenants');
const [only] = out.tenants as Array<{ accounts: Array<Record<string, unknown>> }>;
expect(only.accounts.map((a) => a.principal)).toEqual(['alice@contoso.com', 'carol@contoso.com']);
expect(only.accounts[0]).toMatchObject({ plan: 'community', today: { used: 12, limit: 50 }, month: { used: 40, limit: 250 }, role: null });
expect(only.accounts[1]).toMatchObject({ today: { used: null, limit: 50 }, month: { used: null, limit: 250 } });
});
it('tools keep their routes and quality figures; the other views return their one list', () => {
const data = overview();
const tools = shapeOverview(data, 'tools').tools as Array<Record<string, unknown>>;
expect(tools[0]).toMatchObject({ routes: [{ route: 'GET sessions/{id}', requests: 9 }], avgResultChars: null, overCapCalls: null });
expect(shapeOverview(data, 'quotaHits').quotaHits).toHaveLength(1);
expect(shapeOverview(data, 'minuteLimits')).toMatchObject({ mcpServerLimit: 60 });
expect(shapeOverview(data, 'daily').daily).toEqual(data.daily);
for (const view of ['tools', 'tenants', 'quotaHits', 'minuteLimits', 'daily'] as const) {
expect(shapeOverview(data, view)).not.toHaveProperty('totals');
}
});
});
describe('shapeOrganization', () => {
it('names the windows and sums the accounts of the range', () => {
const data: GetMcpOrganizationUsageResponse = {
tenantId: TENANT_A,
dateFrom: '20261101',
dateTo: '20261105',
users: [
{ userId: OID, userPrincipalName: 'alice@contoso.com', requestsToday: 10, requestsThisMonth: 40, requestsInRange: 40, lastRequestAt: AT },
{ userId: 'oid-2', requestsToday: 0, requestsThisMonth: 2, requestsInRange: 2 },
],
quota: { tenantPlan: 'community', dailyLimit: 100, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 42 },
daily: [{ date: '20261105', requests: 10 }],
};
const out = shapeOrganization(data);
expect(out).toMatchObject({ view: 'organization', plan: 'community', today: { used: 10, limit: 100 }, month: { used: 42, limit: 250 }, requests: 42 });
expect((out.accounts as Array<Record<string, unknown>>)[1]).toEqual(
{ principal: null, userId: 'oid-2', requestsInRange: 2, requestsToday: 0, requestsThisMonth: 2, lastRequestAt: null });
});
});
describe('shapeAccount', () => {
it('folds the counter rows into tools with their paths and into days', () => {
const row = (date: string, endpoint: string, requestCount: number, lastRequestAt = AT) =>
({ userId: OID, userPrincipalName: 'alice@contoso.com', tenantId: TENANT_A, endpoint, date, requestCount, lastRequestAt });
const data: GetMcpUserUsageResponse = {
userId: OID,
records: [
row('20261104', 'get_session_summary:sessions/{id}', 6),
row('20261105', 'get_session_summary:sessions/{id}/events', 9),
row('20261105', 'get_session_summary:sessions/{id}', 3),
row('20261105', 'auth/me', 1),
],
};
const out = shapeAccount(data, { from: '20261101', to: '20261105' });
expect(out).toMatchObject({ userId: OID, principal: 'alice@contoso.com', tenantId: TENANT_A, requests: 19 });
expect(out.byTool).toEqual([
{ tool: 'get_session_summary', requests: 18, paths: [{ path: 'sessions/{id}', requests: 9 }, { path: 'sessions/{id}/events', requests: 9 }] },
{ tool: NO_TOOL, requests: 1, paths: [{ path: 'auth/me', requests: 1 }] },
]);
expect(out.byDay).toEqual([{ date: '20261104', requests: 6 }, { date: '20261105', requests: 13 }]);
});
it('answers an account without rows with zeros, not an error', () => {
expect(shapeAccount({ userId: OID, records: [] }, { from: '20261105', to: '20261105' }))
.toEqual({ view: 'account', userId: OID, principal: null, tenantId: null, window: { from: '20261105', to: '20261105' }, requests: 0, byTool: [], byDay: [] });
});
});
// ── The tool ──────────────────────────────────────────────────────────────────
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
function handler(): Handler {
const handlers: Record<string, Handler> = {};
const fake = { registerTool: (name: string, _def: unknown, h: Handler) => { handlers[name] = h; } };
registerAdminTools(fake as never, true, true, false);
return handlers.get_api_usage;
}
describe('get_api_usage — routing', () => {
beforeEach(() => apiFetchMock.mockReset());
it('reads the overview for the overview views, with the platform-admin switch and a long timeout', async () => {
apiFetchMock.mockResolvedValueOnce(overview());
const result = await handler()({ view: 'summary', days: 7, includePlatformAdmins: true });
expect(result.isError).toBeUndefined();
const [path, init] = apiFetchMock.mock.calls[0] as [string, { signal?: AbortSignal }];
expect(path).toBe('/api/global/metrics/mcp-usage/overview?days=7&platformAdmins=include');
expect(init.signal).toBeDefined();
expect(JSON.parse(result.content[0].text).view).toBe('summary');
});
it('reads one tenant and one account over the trailing window', async () => {
apiFetchMock.mockResolvedValue({ tenantId: TENANT_A, dateFrom: '20261105', dateTo: '20261105', users: [], quota: { tenantPlan: 'pro', dailyLimit: 0, monthlyLimit: 0, dailyUsed: 0, monthlyUsed: 0 }, daily: [] });
await handler()({ view: 'organization', days: 1, tenantId: TENANT_A });
expect(apiFetchMock.mock.calls[0][0]).toMatch(new RegExp(`^/api/global/metrics/mcp-usage/organization\\?tenantId=${TENANT_A}&dateFrom=\\d{8}&dateTo=\\d{8}$`));
apiFetchMock.mockResolvedValue({ userId: OID, records: [] });
await handler()({ view: 'account', days: 1, userId: OID });
expect(apiFetchMock.mock.calls[1][0]).toMatch(new RegExp(`^/api/metrics/mcp-usage/user/${OID}\\?dateFrom=\\d{8}&dateTo=\\d{8}$`));
});
it('refuses organization without tenantId and account without userId, before any request', async () => {
const noTenant = await handler()({ view: 'organization', days: 30 });
const noUser = await handler()({ view: 'account', days: 30 });
expect(noTenant.isError).toBe(true);
expect(noTenant.content[0].text).toMatch(/tenantId is required for view=organization/);
expect(noUser.isError).toBe(true);
expect(noUser.content[0].text).toMatch(/userId .* is required for view=account/);
expect(apiFetchMock).not.toHaveBeenCalled();
});
it('a UPN is not an object id', () => {
expect(ObjectIdSchema.safeParse(OID).success).toBe(true);
expect(ObjectIdSchema.safeParse('alice@contoso.com').success).toBe(false);
expect(ObjectIdSchema.safeParse('../global/metrics').success).toBe(false);
});
});
@@ -40,10 +40,9 @@ interface Role {
maxCatalogChars: number;
}
const ROLES: Role[] = [
// Raised 2026-11-06 by +1,761 each: get_tenant_activity, the platform-only usage per signed-up customer tenant —
// three views, the window and one lifecycle stage (measured 94,774 and 79,966 chars).
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 94_861 },
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 80_061 },
// Raised 2026-10-28 by +1,100 each: the platform-only get_growth_metrics (measured 1,106 chars).
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 93_100 },
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_100 },
// Raised 2026-11-04 by +400: search_sessions gained the groupTag filter and five Autopilot
// projection keys (measured 53,318 chars; the other roles stayed under their ceilings).
{ label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin', maxCatalogChars: 53_400 },
@@ -57,9 +56,8 @@ const ROLES: Role[] = [
* Shared-mechanics arguments should read the same everywhere (shared.ts helpers); each distinct
* wording is one more contract the model has to reconcile. Ratchet on the variant count in the
* Global Admin catalog (2026-09-22 post-diet baseline; pre-diet: tenantId 28, pageSize 14, continuation 10).
* days 10 since 2026-11-05: get_api_usage's window is bounded by the 90 days the request telemetry keeps.
*/
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 10 };
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 9 };
/**
* Domains a description may name. Anything else that looks like a domain is treated as a
@@ -7,7 +7,7 @@
* sessions; the cap and a truncated flag are now surfaced.
*/
import { describe, it, expect } from 'vitest';
import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateInventory } from '../tools/admin.js';
import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateUsage, paginateInventory } from '../tools/admin.js';
describe('inferGeoGroupBy', () => {
it('treats a single-segment key as country', () => {
@@ -90,6 +90,51 @@ describe('platformWindowEcho', () => {
});
});
describe('paginateUsage', () => {
const records = Array.from({ length: 5 }, (_, i) => ({ endpoint: `e${i}`, count: i }));
it('pages the global per-record breakdown and emits a nextLink while more remain', () => {
const out = paginateUsage({ tenantId: 't1', records }, 2);
expect(out.tenantId).toBe('t1'); // top-level fields echoed verbatim
expect(out.totalCount).toBe(5);
expect(out.count).toBe(2);
expect(out.offset).toBe(0);
expect(out.records).toEqual(records.slice(0, 2));
expect(out.nextLink).toBe('usage-offset:2');
});
it('follows a continuation cursor to the next slice', () => {
const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:2');
expect(out.offset).toBe(2);
expect(out.records).toEqual(records.slice(2, 4));
expect(out.nextLink).toBe('usage-offset:4');
});
it('drops nextLink on the final page', () => {
const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:4');
expect(out.count).toBe(1);
expect(out.records).toEqual(records.slice(4));
expect(out.nextLink).toBeNull();
});
it('pages the daily "summaries" array too, preserving the key', () => {
const summaries = [{ date: '2026-06-01' }, { date: '2026-06-02' }];
const out = paginateUsage({ tenantId: 't1', summaries }, 1);
expect(out.summaries).toEqual(summaries.slice(0, 1));
expect(Object.prototype.hasOwnProperty.call(out, 'records')).toBe(false);
expect(out.nextLink).toBe('usage-offset:1');
});
it('passes an unexpected shape through untouched', () => {
const weird = { tenantId: 't1', somethingElse: 42 };
expect(paginateUsage(weird, 200)).toEqual(weird);
});
it('handles a null/non-object payload defensively', () => {
expect(paginateUsage(null, 200)).toEqual({ records: [], totalCount: 0, count: 0, offset: 0, nextLink: null });
});
});
describe('paginateInventory', () => {
const inventory = Array.from({ length: 5 }, (_, i) => ({ normalizedName: `app-${i}`, cpeUri: '' }));
const payload = { success: true, tenantId: 't1', total: 5, matched: 0, unmatched: 5, inventory };
@@ -1,433 +0,0 @@
/**
* get_tenant_activity: the shaping of the tenant activity views (tenant-activity-shape.ts) and the tool's routing.
* - rows are slim: a ranking row carries the ranking columns, a lifecycle row the figures of its stage;
* - absent wire values become explicit nulls (no previous enrollments, no finished session, no sign-in history);
* - every lifecycle list has its own order, and equal keys keep the backend order;
* - every list is cut to its cap and states its full length, and a realistic platform stays under the response cap;
* - platform roles only; days reaches the backend, view and stage stay in the MCP server.
*
* apiFetch is mocked so these run with no backend / token.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
const { apiFetchMock } = vi.hoisted(() => ({ apiFetchMock: vi.fn() }));
vi.mock('../client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../client.js')>();
return { ...actual, apiFetch: apiFetchMock };
});
import { registerAdminTools } from '../tools/admin.js';
import { MAX_RESULT_SIZE_CHARS } from '../tools/shared.js';
import { LIFECYCLE_LISTS, TENANT_ACTIVITY_CAPS, TENANT_ACTIVITY_VIEWS, shapeTenantActivity, type TenantActivityView } from '../tenant-activity-shape.js';
import { TENANT_ACTIVITY_STAGES, type TenantActivityStage } from '../generated/wire-vocabularies.generated.js';
import type { GetTenantActivityResponse, TenantActivityTenantItem } from '../generated/wire-types.generated.js';
type Row = Record<string, unknown>;
interface List { total: number; truncated: boolean; rows: Row[] }
/** Placeholder tenant ids: a GUID per index, never a real tenant. */
const tenantId = (i: number) => `aaaaaaaa-0000-4000-8000-${String(i).padStart(12, '0')}`;
function tenant(i: number, over: Partial<TenantActivityTenantItem> = {}): TenantActivityTenantItem {
return {
tenantId: tenantId(i),
domainName: `tenant${i}.contoso.example`,
edition: 'community',
stage: 'active',
enrollments: 0,
enrollmentsPrevious: 0,
succeeded: 0,
failed: 0,
activeDays: 0,
lifetimeEnrollments: 0,
lifetimeSucceeded: 0,
lifetimeEvents: 0,
...over,
};
}
function response(tenants: TenantActivityTenantItem[], over: Partial<GetTenantActivityResponse> = {}): GetTenantActivityResponse {
return {
windowDays: 30,
windowStart: '2026-10-07',
usageDaysThrough: '2026-11-05',
previousWindowStart: '2026-09-07',
rowsFrom: '2026-05-10',
portalActivityAvailable: true,
totals: {
signedUpTenants: tenants.length,
activeTenants: 2,
activeTenantsPrevious: 1,
activeTenantsTrendPercent: 100,
enrollments: 43,
enrollmentsPrevious: 30,
enrollmentsTrendPercent: 43,
succeeded: 38,
failed: 2,
successPercent: 95,
top10SharePercent: 100,
stages: { neverEnrolled: 1, dormant: 0, new: 0, declining: 0, active: 2, quiet: 0 },
portalUsers: 5,
portalTenants: 2,
},
daily: [
{ day: '2026-11-04', enrollments: 0, succeeded: 0, failed: 0, activeTenants: 0 },
{ day: '2026-11-05', enrollments: 43, succeeded: 38, failed: 2, activeTenants: 2 },
],
tenants,
...over,
};
}
const stagesOf = (out: Row) => out.stages as Record<string, List>;
const ids = (list: List) => list.rows.map((r) => r.tenantId);
// ── Summary ───────────────────────────────────────────────────────────────────
describe('shapeTenantActivity — summary', () => {
const contoso = tenant(1, {
domainName: 'contoso.example', companyName: 'Contoso Ltd', edition: 'pro', stage: 'active',
onboardedAt: '2026-03-02T09:15:00.1234567Z', enrollments: 40, enrollmentsPrevious: 30, trendPercent: 33,
succeeded: 36, failed: 2, successPercent: 95, activeDays: 18, dailyEnrollments: [1, 2, 3],
firstEnrollmentDay: '2026-03-03', lastActiveDay: '2026-11-05', lastCounterUpdateAt: '2026-11-06T07:00:00Z',
lifetimeEnrollments: 900, lifetimeSucceeded: 850, lifetimeEvents: 120_000, portalUsers: 4, lastPortalVisitAt: '2026-11-06T08:00:00Z',
});
// Enrolled, but no previous enrollments, no finished session and no portal figures on the wire.
const fabrikam = tenant(2, { domainName: 'fabrikam.example', stage: 'new', enrollments: 3, activeDays: 1, lastActiveDay: '2026-11-05' });
const idle = tenant(3, { stage: 'neverEnrolled', onboardedAt: '2026-11-01T10:00:00Z' });
it('carries the window, the totals with explicit nulls and the daily series', () => {
const data = response([contoso, fabrikam, idle]);
const { activeTenantsTrendPercent: _a, enrollmentsTrendPercent: _e, successPercent: _s, top10SharePercent: _t, portalUsers: _p, portalTenants: _q, ...withoutOptional } = data.totals;
const out = shapeTenantActivity({ ...data, totals: withoutOptional }, 'summary');
expect(out.view).toBe('summary');
expect(out.window).toEqual({ days: 30, start: '2026-10-07', through: '2026-11-05', previousStart: '2026-09-07', rowsFrom: '2026-05-10' });
expect(out.portalActivityAvailable).toBe(true);
expect(out.totals).toEqual({
...withoutOptional,
activeTenantsTrendPercent: null,
enrollmentsTrendPercent: null,
successPercent: null,
top10SharePercent: null,
portalUsers: null,
portalTenants: null,
});
expect(out.daily).toEqual(data.daily);
});
it('keeps the totals the backend computed', () => {
const data = response([contoso]);
expect(shapeTenantActivity(data, 'summary').totals).toEqual(data.totals);
});
it('lists the most active tenants as slim ranking rows, absent figures as null', () => {
const mostActive = shapeTenantActivity(response([contoso, fabrikam, idle]), 'summary').mostActive as List;
expect(mostActive).toMatchObject({ total: 2, truncated: false });
expect(mostActive.rows).toEqual([
{
tenantId: tenantId(1), domain: 'contoso.example', edition: 'pro', stage: 'active', enrollments: 40, previous: 30,
trendPercent: 33, successPercent: 95, activeDays: 18, lastActiveDay: '2026-11-05', portalUsers: 4,
},
{
tenantId: tenantId(2), domain: 'fabrikam.example', edition: 'community', stage: 'new', enrollments: 3, previous: 0,
trendPercent: null, successPercent: null, activeDays: 1, lastActiveDay: '2026-11-05', portalUsers: null,
},
]);
});
it('shows the head of the four lifecycle lists and points to view=lifecycle', () => {
const out = shapeTenantActivity(response([contoso, fabrikam, idle]), 'summary');
const stages = stagesOf(out);
expect(Object.keys(stages)).toEqual([...LIFECYCLE_LISTS]);
expect(stages.new).toEqual({ total: 1, truncated: false, rows: [{ tenantId: tenantId(2), domain: 'fabrikam.example', edition: 'community', onboarded: null, firstEnrollmentDay: null, enrollments: 3 }] });
expect(stages.neverEnrolled.rows.map((r) => r.onboarded)).toEqual(['2026-11-01']);
expect(stages.declining).toEqual({ total: 0, truncated: false, rows: [] });
expect(out.note).toMatch(/view=lifecycle .* stage=/);
});
});
// ── Lifecycle rows ────────────────────────────────────────────────────────────
describe('shapeTenantActivity — lifecycle rows', () => {
const full: Partial<TenantActivityTenantItem> = {
edition: 'pro-trial', companyName: 'Contoso Ltd', onboardedAt: '2026-08-20T13:45:10.1234567Z', enrollments: 6,
enrollmentsPrevious: 20, trendPercent: -70, succeeded: 5, failed: 1, successPercent: 83, activeDays: 4,
dailyEnrollments: [6], firstEnrollmentDay: '2026-10-12', lastActiveDay: '2026-11-04', lastCounterUpdateAt: '2026-11-04T18:00:00Z',
lifetimeEnrollments: 75, lifetimeSucceeded: 70, lifetimeEvents: 9_000, portalUsers: 3, lastPortalVisitAt: '2026-11-02T07:30:00.5Z',
};
const base = { tenantId: tenantId(1), domain: 'tenant1.contoso.example', edition: 'pro-trial' };
const rowOf = (stage: TenantActivityStage, over: Partial<TenantActivityTenantItem> = full) =>
stagesOf(shapeTenantActivity(response([tenant(1, { ...over, stage })]), 'lifecycle', stage))[stage].rows[0];
it('carries id, domain and edition plus the figures of its stage only', () => {
expect(rowOf('new')).toEqual({ ...base, onboarded: '2026-08-20', firstEnrollmentDay: '2026-10-12', enrollments: 6 });
expect(rowOf('declining')).toEqual({ ...base, previous: 20, enrollments: 6, trendPercent: -70, lastActiveDay: '2026-11-04' });
expect(rowOf('dormant')).toEqual({ ...base, lifetimeEnrollments: 75, lastActiveDay: '2026-11-04' });
expect(rowOf('neverEnrolled')).toEqual({ ...base, onboarded: '2026-08-20', lastPortalVisit: '2026-11-02' });
expect(rowOf('active')).toEqual({ ...base, enrollments: 6, lastActiveDay: '2026-11-04' });
expect(rowOf('quiet')).toEqual({ ...base, enrollments: 6, lastActiveDay: '2026-11-04' });
});
it('turns absent figures into null', () => {
const bare = { edition: 'pro-trial' };
expect(rowOf('new', bare)).toEqual({ ...base, onboarded: null, firstEnrollmentDay: null, enrollments: 0 });
expect(rowOf('declining', bare)).toEqual({ ...base, previous: 0, enrollments: 0, trendPercent: null, lastActiveDay: null });
expect(rowOf('dormant', bare)).toEqual({ ...base, lifetimeEnrollments: 0, lastActiveDay: null });
expect(rowOf('neverEnrolled', bare)).toEqual({ ...base, onboarded: null, lastPortalVisit: null });
});
it('without the sign-in history the portal figures are null, never 0', () => {
const data = response([tenant(1, { stage: 'active', enrollments: 5 }), tenant(2, { stage: 'neverEnrolled' })], { portalActivityAvailable: false });
const summary = shapeTenantActivity(data, 'summary');
expect(summary.portalActivityAvailable).toBe(false);
expect((summary.mostActive as List).rows[0].portalUsers).toBeNull();
expect(stagesOf(summary).neverEnrolled.rows[0].lastPortalVisit).toBeNull();
expect((shapeTenantActivity(data, 'ranking').tenants as List).rows[0].portalUsers).toBeNull();
});
it('view=lifecycle without stage shows the four lists; with stage only that one', () => {
const data = response([tenant(1, { stage: 'quiet' }), tenant(2, { stage: 'dormant' })]);
const four = shapeTenantActivity(data, 'lifecycle');
expect(Object.keys(stagesOf(four))).toEqual([...LIFECYCLE_LISTS]);
expect(four.note).toMatch(/active and quiet/);
expect(four).not.toHaveProperty('totals');
const quiet = shapeTenantActivity(data, 'lifecycle', 'quiet');
expect(Object.keys(stagesOf(quiet))).toEqual(['quiet']);
expect(ids(stagesOf(quiet).quiet)).toEqual([tenantId(1)]);
});
});
// ── List order ────────────────────────────────────────────────────────────────
describe('shapeTenantActivity — list order', () => {
const order = (stage: TenantActivityStage, tenants: TenantActivityTenantItem[]) =>
ids(stagesOf(shapeTenantActivity(response(tenants), 'lifecycle', stage))[stage]);
it('new: the latest first day first; no first day (sessions after the last daily row) before all', () => {
expect(order('new', [
tenant(1, { stage: 'new', firstEnrollmentDay: '2026-10-10' }),
tenant(2, { stage: 'new' }),
tenant(3, { stage: 'new', firstEnrollmentDay: '2026-11-01' }),
tenant(4, { stage: 'new', firstEnrollmentDay: '2026-10-10' }),
])).toEqual([tenantId(2), tenantId(3), tenantId(1), tenantId(4)]);
});
it('declining: the largest drop against the previous window first', () => {
expect(order('declining', [
tenant(1, { stage: 'declining', enrollmentsPrevious: 10, enrollments: 5 }),
tenant(2, { stage: 'declining', enrollmentsPrevious: 20, enrollments: 2 }),
tenant(3, { stage: 'declining', enrollmentsPrevious: 8, enrollments: 4 }),
tenant(4, { stage: 'declining', enrollmentsPrevious: 12, enrollments: 7 }),
])).toEqual([tenantId(2), tenantId(1), tenantId(4), tenantId(3)]);
});
it('dormant: the most enrollments since signup first', () => {
expect(order('dormant', [
tenant(1, { stage: 'dormant', lifetimeEnrollments: 40 }),
tenant(2, { stage: 'dormant', lifetimeEnrollments: 300 }),
tenant(3, { stage: 'dormant', lifetimeEnrollments: 40 }),
tenant(4, { stage: 'dormant', lifetimeEnrollments: 7 }),
])).toEqual([tenantId(2), tenantId(1), tenantId(3), tenantId(4)]);
});
it('neverEnrolled: the newest signup first, by instant; a row without a signup time last', () => {
expect(order('neverEnrolled', [
tenant(1, { stage: 'neverEnrolled' }),
tenant(2, { stage: 'neverEnrolled', onboardedAt: '2026-10-01T10:00:00Z' }),
tenant(3, { stage: 'neverEnrolled', onboardedAt: '2026-10-01T10:00:00.5000000Z' }),
tenant(4, { stage: 'neverEnrolled', onboardedAt: '2026-11-02T08:00:00Z' }),
tenant(5, { stage: 'neverEnrolled', onboardedAt: '2026-10-01T10:00:00Z' }),
])).toEqual([tenantId(4), tenantId(3), tenantId(2), tenantId(5), tenantId(1)]);
});
it('active and quiet keep the backend order; the input is not reordered', () => {
const tenants = [
tenant(1, { stage: 'active', enrollments: 2, lifetimeEnrollments: 900 }),
tenant(2, { stage: 'active', enrollments: 9, lifetimeEnrollments: 10 }),
tenant(3, { stage: 'quiet', lastActiveDay: '2026-11-06' }),
tenant(4, { stage: 'quiet', lastActiveDay: '2026-10-01' }),
];
expect(order('active', tenants)).toEqual([tenantId(1), tenantId(2)]);
expect(order('quiet', tenants)).toEqual([tenantId(3), tenantId(4)]);
expect(tenants.map((t) => t.tenantId)).toEqual([1, 2, 3, 4].map(tenantId));
});
});
// ── Cuts ──────────────────────────────────────────────────────────────────────
describe('shapeTenantActivity — every list is cut to its cap with its full length', () => {
it('the ranking: tenants with enrollments only, in the backend order, 15 in the summary and 300 in view=ranking', () => {
const enrolled = Array.from({ length: 320 }, (_, i) => tenant(i, { enrollments: 400 - i }));
const data = response([...enrolled, tenant(900, { stage: 'neverEnrolled' })]);
const mostActive = shapeTenantActivity(data, 'summary').mostActive as List;
expect(mostActive).toMatchObject({ total: 320, truncated: true });
expect(mostActive.rows).toHaveLength(TENANT_ACTIVITY_CAPS.mostActive);
expect(TENANT_ACTIVITY_CAPS.mostActive).toBe(15);
const ranking = shapeTenantActivity(data, 'ranking').tenants as List;
expect(ranking).toMatchObject({ total: 320, truncated: true });
expect(ranking.rows).toHaveLength(300);
expect(ids(ranking)).toEqual(enrolled.slice(0, 300).map((t) => t.tenantId));
});
it('a lifecycle list: 10 in the summary, 100 in view=lifecycle, 500 with stage', () => {
const data = response(Array.from({ length: 520 }, (_, i) => tenant(i, { stage: 'neverEnrolled' })));
const head = stagesOf(shapeTenantActivity(data, 'summary')).neverEnrolled;
expect(head).toMatchObject({ total: 520, truncated: true });
expect(head.rows).toHaveLength(10);
const lifecycle = stagesOf(shapeTenantActivity(data, 'lifecycle')).neverEnrolled;
expect(lifecycle).toMatchObject({ total: 520, truncated: true });
expect(lifecycle.rows).toHaveLength(100);
const one = stagesOf(shapeTenantActivity(data, 'lifecycle', 'neverEnrolled')).neverEnrolled;
expect(one).toMatchObject({ total: 520, truncated: true });
expect(one.rows).toHaveLength(500);
});
it('a list under its cap is whole and not truncated', () => {
const data = response(Array.from({ length: 100 }, (_, i) => tenant(i, { stage: 'dormant' })));
expect(stagesOf(shapeTenantActivity(data, 'lifecycle')).dormant).toMatchObject({ total: 100, truncated: false });
expect(stagesOf(shapeTenantActivity(data, 'lifecycle')).dormant.rows).toHaveLength(100);
expect(stagesOf(shapeTenantActivity(data, 'lifecycle', 'dormant')).dormant).toMatchObject({ total: 100, truncated: false });
});
});
// ── Size ──────────────────────────────────────────────────────────────────────
/** A realistic large platform: 1,000 signups, a 90-day window, full wire rows (about 500 characters, 950 with the daily series). */
function platform(): GetTenantActivityResponse {
const mix: ReadonlyArray<readonly [TenantActivityStage, number]> = [
['neverEnrolled', 600], ['dormant', 150], ['new', 50], ['declining', 50], ['active', 120], ['quiet', 30],
];
const editions = ['community', 'pro', 'pro-trial', 'pro-msp'];
const tenants: TenantActivityTenantItem[] = [];
let i = 0;
for (const [stage, count] of mix) {
for (let k = 0; k < count; k++, i++) {
const enrollments = stage === 'active' ? 2_000 + i : stage === 'new' ? 40 + k : stage === 'declining' ? 9 : 0;
const enrolledEver = stage !== 'neverEnrolled';
tenants.push({
tenantId: `${(i % 16).toString(16).repeat(8)}-1234-4abc-9def-${String(i).padStart(12, '0')}`,
domainName: `subsidiary-${String(i).padStart(4, '0')}-manufacturing.contoso.example`,
companyName: `Contoso Manufacturing Subsidiary ${i} International GmbH`,
edition: editions[i % editions.length],
stage,
onboardedAt: '2026-04-17T13:45:10.1234567Z',
enrollments,
enrollmentsPrevious: stage === 'declining' ? 1_250 : enrollments,
...(enrolledEver ? { trendPercent: -47 } : {}),
succeeded: Math.floor(enrollments * 0.9),
failed: Math.floor(enrollments * 0.05),
...(enrollments > 0 ? { successPercent: 94, dailyEnrollments: Array.from({ length: 90 }, (_, d) => 10 + ((d * 7 + i) % 90)) } : {}),
activeDays: enrollments > 0 ? 88 : 0,
...(enrolledEver ? { firstEnrollmentDay: '2026-05-21', lastActiveDay: '2026-11-05', lastCounterUpdateAt: '2026-11-06T07:12:45.1234567Z' } : {}),
lifetimeEnrollments: enrolledEver ? 123_456 : 0,
lifetimeSucceeded: enrolledEver ? 112_233 : 0,
lifetimeEvents: enrolledEver ? 45_678_901 : 0,
portalUsers: 12,
lastPortalVisitAt: '2026-11-06T08:30:00.1234567Z',
});
}
}
const daily = Array.from({ length: 90 }, (_, d) => ({
day: new Date(Date.UTC(2026, 7, 8 + d)).toISOString().slice(0, 10), enrollments: 1_234, succeeded: 1_100, failed: 80, activeTenants: 150,
}));
return response(tenants, { windowDays: 90, windowStart: '2026-08-08', previousWindowStart: '2026-05-10', daily });
}
describe('shapeTenantActivity — size', () => {
it('every view and every stage of 1,000 signups stays under the response cap of the tool', () => {
const data = platform();
expect(data.tenants).toHaveLength(1_000);
const fullRow = JSON.stringify(data.tenants.find((t) => t.stage === 'active')).length;
expect(fullRow).toBeGreaterThan(500); // a realistic wire row, so the cut views have real work to do
const calls: Array<[TenantActivityView, TenantActivityStage?]> = [
...TENANT_ACTIVITY_VIEWS.map((view): [TenantActivityView] => [view]),
...TENANT_ACTIVITY_STAGES.map((stage): [TenantActivityView, TenantActivityStage] => ['lifecycle', stage]),
];
const oversized = calls
.map(([view, stage]) => ({ call: `${view}${stage ? `+${stage}` : ''}`, chars: JSON.stringify(shapeTenantActivity(data, view, stage)).length }))
.filter((s) => s.chars >= MAX_RESULT_SIZE_CHARS.adminStream);
expect(oversized).toEqual([]);
// The fixture is big enough to hit the caps it guards.
expect(stagesOf(shapeTenantActivity(data, 'lifecycle', 'neverEnrolled')).neverEnrolled).toMatchObject({ total: 600, truncated: true });
expect(stagesOf(shapeTenantActivity(data, 'lifecycle')).dormant).toMatchObject({ total: 150, truncated: true });
});
});
// ── The tool ──────────────────────────────────────────────────────────────────
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
interface Registered { def: { inputSchema: Record<string, any> }; handler: Handler }
function registered(ga: boolean, strictGa: boolean, delegated = false): Record<string, Registered> {
const tools: Record<string, Registered> = {};
const fake = { registerTool: (name: string, def: Registered['def'], handler: Handler) => { tools[name] = { def, handler }; } };
registerAdminTools(fake as never, ga, strictGa, delegated);
return tools;
}
describe('get_tenant_activity — registration', () => {
it('is listed for Global Admins and Global Readers only', () => {
expect(registered(true, true)).toHaveProperty('get_tenant_activity');
expect(registered(true, false)).toHaveProperty('get_tenant_activity');
expect(registered(false, false)).not.toHaveProperty('get_tenant_activity');
expect(registered(false, false, true)).not.toHaveProperty('get_tenant_activity');
});
it('takes the stages from the generated vocabulary and the views from the shaper', () => {
const { inputSchema } = registered(true, false).get_tenant_activity.def;
expect(inputSchema.stage.unwrap().options).toEqual([...TENANT_ACTIVITY_STAGES]);
expect(inputSchema.view.parse(undefined)).toBe('summary');
for (const view of TENANT_ACTIVITY_VIEWS) expect(inputSchema.view.parse(view)).toBe(view);
expect(inputSchema.days.safeParse(91).success).toBe(false);
});
});
describe('get_tenant_activity — request', () => {
beforeEach(() => apiFetchMock.mockReset());
const handler = () => registered(true, true).get_tenant_activity.handler;
it('sends days to the route and keeps view and stage in the MCP server', async () => {
apiFetchMock.mockResolvedValueOnce(response([tenant(1, { stage: 'dormant', lifetimeEnrollments: 12 })]));
const result = await handler()({ days: 7, view: 'lifecycle', stage: 'dormant' });
expect(result.isError).toBeUndefined();
expect(apiFetchMock.mock.calls[0][0]).toBe('/api/global/metrics/tenant-activity?days=7');
const out = JSON.parse(result.content[0].text);
expect(out.view).toBe('lifecycle');
expect(Object.keys(out.stages)).toEqual(['dormant']);
});
it('leaves an unset window to the backend default', async () => {
apiFetchMock.mockResolvedValueOnce(response([]));
const result = await handler()({ view: 'summary' });
expect(apiFetchMock.mock.calls[0][0]).toBe('/api/global/metrics/tenant-activity');
expect(JSON.parse(result.content[0].text).view).toBe('summary');
});
it('refuses stage with another view than lifecycle, before any request', async () => {
const result = await handler()({ view: 'ranking', stage: 'dormant' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/stage applies to view=lifecycle only/);
expect(apiFetchMock).not.toHaveBeenCalled();
});
});
@@ -105,13 +105,19 @@ suite('get_agent_efficiency_metrics', () => {
});
suite('get_api_usage', () => {
it('should return the all-tenant overview with its totals', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/overview?days=7`);
expect(data).toHaveProperty('totals');
expect(typeof data.totals.requests).toBe('number');
expect(Array.isArray(data.tenants)).toBe(true);
expect(Array.isArray(data.tools)).toBe(true);
expect(Array.isArray(data.minuteLimits.accounts)).toBe(true);
it('should return daily usage summary', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/daily`);
expect(Array.isArray(data.summaries)).toBe(true);
if (data.summaries.length > 0) {
expect(data.summaries[0]).toHaveProperty('date');
expect(data.summaries[0]).toHaveProperty('totalRequests');
}
});
it('should return global per-record usage', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage`);
// Response structure depends on implementation, just verify no error
expect(data).toBeDefined();
});
});
@@ -146,8 +146,6 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'get_session_report_download',
'get_session_summary',
'get_software_inventory',
// Platform scope (GA + Global Reader): GlobalReadOrAdmin endpoint, usage per signed-up customer tenant.
'get_tenant_activity',
'get_tenant_config',
'get_tenant_config_schema',
// Available to every role: backed by MemberRead endpoints (tenant) with role-aware
@@ -234,7 +232,6 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'get_platform_metrics',
'get_rule_submission',
'get_session_report_download',
'get_tenant_activity',
'get_tenant_config',
'get_tenant_config_schema',
'get_verdict_calibration',
@@ -35,8 +35,6 @@ const ALLOWED_INLINE_ENUMS: Record<string, string> = {
'search_knowledge corpus selector — MCP-side, spans two backend catalogs plus the error-code catalog.',
"'inventory', 'unmatched'":
'get_software_inventory scope selector — an MCP-side view switch.',
"...OVERVIEW_VIEWS, 'organization', 'account'":
'get_api_usage view selector — an MCP-side view switch over three backend routes (api-usage-shape.ts).',
"'cmtrace', 'text'":
'test_log_pattern input format — an MCP-side parser switch.',
"'analyze', 'gather'":
@@ -1,265 +0,0 @@
/**
* LLM-facing shaping of the MCP usage views of get_api_usage:
* - the all-tenant overview (GET /api/global/metrics/mcp-usage/overview, D-416) — one list per view, or the summary;
* - one tenant's organization budget (GET /api/global/metrics/mcp-usage/organization?tenantId=);
* - one account's quota counters (GET /api/metrics/mcp-usage/user/{userId}).
*
* The backend computes every figure (totals, minute shares, rankings) once for the admin page and this tool. This
* layer only picks what a view shows, states the full length of every list the summary cuts, names tenants, folds an
* account's counter rows into sums per tool and per day, and turns absent values into explicit nulls (the wire omits
* them, which reads as "not applicable"). A quota window is { used, limit }: limit 0 = unlimited, used null = the
* counter could not be read.
*/
import type {
GetGlobalMcpUsageOverviewResponse,
GetMcpOrganizationUsageResponse,
GetMcpUserUsageResponse,
McpUsageAccountItem,
McpUsageMinuteItem,
McpUsageQuotaHitItem,
McpUsageTenantItem,
McpUsageToolItem,
} from './generated/wire-types.generated.js';
export const OVERVIEW_VIEWS = ['summary', 'tools', 'tenants', 'quotaHits', 'minuteLimits', 'daily'] as const;
export type OverviewView = (typeof OVERVIEW_VIEWS)[number];
/** How many rows of each list the summary shows; the full list is one view away. */
export const SUMMARY_HEAD = { tools: 10, tenants: 10, quotaHits: 10, minuteLimits: 5 } as const;
/** The tool of an account's counter key that carries none (a request sent without a tool name). */
export const NO_TOOL = '(no tool)';
interface QuotaWindow {
used: number | null;
limit: number;
}
/** The wire marks an unreadable counter with -1. */
function quotaWindow(used: number, limit: number): QuotaWindow {
return { used: used < 0 ? null : used, limit };
}
const dayKey = (d: Date) => d.toISOString().slice(0, 10).replace(/-/g, '');
/** The trailing window of `days` whole UTC days ending today, as yyyyMMdd — the overview's own window. */
export function usageWindow(days: number, now: Date = new Date()): { from: string; to: string } {
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (days - 1)));
return { from: dayKey(from), to: dayKey(now) };
}
// ── Overview ──────────────────────────────────────────────────────────────────
function overviewHead(data: GetGlobalMcpUsageOverviewResponse) {
return {
window: { from: data.dateFrom, to: data.dateTo },
includesPlatformAdmins: data.includesPlatformAdmins,
telemetry: {
requestsAvailable: data.telemetry.requestsAvailable,
mcpLogAvailable: data.telemetry.mcpLogAvailable,
mcpLogFrom: data.telemetry.mcpLogFrom ?? null,
partial: data.telemetry.partial ?? false,
},
};
}
function toolRow(t: McpUsageToolItem) {
return {
tool: t.tool,
calls: t.calls,
requestsPerCall: t.requestsPerCall,
// null: the tool runs in the MCP server alone and never reaches the backend.
accounts: t.accounts ?? null,
tenants: t.tenants ?? null,
errorRate: t.errorRate ?? null,
p95DurationMs: t.p95DurationMs ?? null,
};
}
function tenantRow(t: McpUsageTenantItem) {
return {
tenantId: t.tenantId,
domainName: t.domainName ?? null,
plan: t.quota.tenantPlan,
today: quotaWindow(t.quota.dailyUsed, t.quota.dailyLimit),
month: quotaWindow(t.quota.monthlyUsed, t.quota.monthlyLimit),
calls: t.calls,
requests: t.requests,
quotaHits: t.quotaHits,
lastRequestAt: t.lastRequestAt ?? null,
};
}
function accountRow(a: McpUsageAccountItem) {
return {
principal: a.principal,
userId: a.userId ?? null,
role: a.role ?? null,
isPlatformAdmin: a.isPlatformAdmin,
plan: a.quota?.plan ?? null,
today: a.quota ? quotaWindow(a.quota.dailyUsed, a.quota.dailyLimit) : null,
month: a.quota ? quotaWindow(a.quota.monthlyUsed, a.quota.monthlyLimit) : null,
calls: a.calls,
requests: a.requests,
peakDay: a.peakDay ?? null,
peakDayRequests: a.peakDayRequests ?? null,
quotaHits: a.quotaHits,
topTools: a.topTools,
lastRequestAt: a.lastRequestAt ?? null,
};
}
function quotaHitRow(h: McpUsageQuotaHitItem, domainName: string | null) {
return {
date: h.date,
tenantId: h.tenantId,
domainName,
principal: h.principal ?? null,
level: h.level,
scope: h.scope,
plan: h.plan,
used: h.used ?? null,
limit: h.limit ?? null,
refused: h.refused,
firstAt: h.firstAt,
lastAt: h.lastAt,
};
}
function minuteRow(m: McpUsageMinuteItem, domainName: string | null) {
return {
tenantId: m.tenantId,
domainName,
principal: m.principal,
sharePercent: m.sharePercent,
peakCalls: m.peakCalls,
peakCallsAt: m.peakCallsAt ?? null,
mcpRefused: m.mcpRefused,
peakRequests: m.peakRequests,
peakRequestsAt: m.peakRequestsAt ?? null,
backendLimit: m.backendLimit,
backendRefused: m.backendRefused,
};
}
/** One view of the overview: the summary (every list cut to its head, full lengths stated) or one list in full. */
export function shapeOverview(data: GetGlobalMcpUsageOverviewResponse, view: OverviewView): Record<string, unknown> {
const names = new Map(data.tenants.filter((t) => t.domainName).map((t) => [t.tenantId.toLowerCase(), t.domainName!]));
const nameOf = (tenantId: string) => names.get(tenantId.toLowerCase()) ?? null;
const head = { view, ...overviewHead(data) };
const minute = data.minuteLimits;
switch (view) {
case 'summary': {
const lists = {
tools: data.tools.length,
tenants: data.tenants.length,
quotaHits: data.quotaHits.length,
minuteLimits: minute.accounts.length,
};
const cut = (Object.keys(lists) as Array<keyof typeof lists>).some((k) => lists[k] > SUMMARY_HEAD[k]);
return {
...head,
totals: { ...data.totals, calls: data.totals.calls ?? null, mcpOnlyCalls: data.totals.mcpOnlyCalls ?? null },
tools: data.tools.slice(0, SUMMARY_HEAD.tools).map(toolRow),
tenants: data.tenants.slice(0, SUMMARY_HEAD.tenants).map((t) => ({ ...tenantRow(t), accounts: t.accounts.length })),
quotaHits: data.quotaHits.slice(0, SUMMARY_HEAD.quotaHits).map((h) => quotaHitRow(h, nameOf(h.tenantId))),
minuteLimits: {
mcpServerLimit: minute.mcpServerLimit ?? null,
accounts: minute.accounts.slice(0, SUMMARY_HEAD.minuteLimits).map((m) => minuteRow(m, nameOf(m.tenantId))),
},
lists,
...(cut ? { note: 'Lists are cut to their first rows; lists gives each full length, the view of the same name shows it in full.' } : {}),
};
}
case 'tools':
return { ...head, tools: data.tools.map((t) => ({ ...toolRow(t), avgResultChars: t.avgResultChars ?? null, overCapCalls: t.overCapCalls ?? null, routes: t.routes })) };
case 'tenants':
return { ...head, tenants: data.tenants.map((t) => ({ ...tenantRow(t), accounts: t.accounts.map(accountRow) })) };
case 'quotaHits':
return { ...head, quotaHits: data.quotaHits.map((h) => quotaHitRow(h, nameOf(h.tenantId))) };
case 'minuteLimits':
return {
...head,
mcpServerLimit: minute.mcpServerLimit ?? null,
accounts: minute.accounts.map((m) => ({
...minuteRow(m, nameOf(m.tenantId)),
mcpLastRefusedAt: m.mcpLastRefusedAt ?? null,
backendLastRefusedAt: m.backendLastRefusedAt ?? null,
})),
};
case 'daily':
return { ...head, daily: data.daily };
}
}
// ── One tenant ────────────────────────────────────────────────────────────────
/** One tenant's organization budget: its windows, every account charged to it, and the days. */
export function shapeOrganization(data: GetMcpOrganizationUsageResponse): Record<string, unknown> {
return {
view: 'organization',
tenantId: data.tenantId,
window: { from: data.dateFrom, to: data.dateTo },
plan: data.quota.tenantPlan,
today: quotaWindow(data.quota.dailyUsed, data.quota.dailyLimit),
month: quotaWindow(data.quota.monthlyUsed, data.quota.monthlyLimit),
requests: data.users.reduce((sum, u) => sum + u.requestsInRange, 0),
accounts: data.users.map((u) => ({
principal: u.userPrincipalName ?? null,
userId: u.userId,
requestsInRange: u.requestsInRange,
requestsToday: u.requestsToday,
requestsThisMonth: u.requestsThisMonth,
lastRequestAt: u.lastRequestAt ?? null,
})),
daily: data.daily,
};
}
// ── One account ───────────────────────────────────────────────────────────────
/** A counter key is `tool:path`, or the bare path when the request carried no tool name. */
function splitEndpoint(endpoint: string): { tool: string; path: string } {
const colon = endpoint.indexOf(':');
return colon > 0 ? { tool: endpoint.slice(0, colon), path: endpoint.slice(colon + 1) } : { tool: NO_TOOL, path: endpoint };
}
const byRequestsThenName = <T extends { requests: number }>(name: (row: T) => string) =>
(a: T, b: T) => b.requests - a.requests || name(a).localeCompare(name(b));
/** One account's charged requests in the window, by tool (with the paths it requested) and by day. */
export function shapeAccount(data: GetMcpUserUsageResponse, window: { from: string; to: string }): Record<string, unknown> {
const tools = new Map<string, Map<string, number>>();
const days = new Map<string, number>();
let newest: (typeof data.records)[number] | undefined;
for (const record of data.records) {
const { tool, path } = splitEndpoint(record.endpoint);
const paths = tools.get(tool) ?? new Map<string, number>();
paths.set(path, (paths.get(path) ?? 0) + record.requestCount);
tools.set(tool, paths);
days.set(record.date, (days.get(record.date) ?? 0) + record.requestCount);
if (!newest || record.lastRequestAt > newest.lastRequestAt) newest = record;
}
const byTool = [...tools.entries()]
.map(([tool, paths]) => ({
tool,
requests: [...paths.values()].reduce((sum, n) => sum + n, 0),
paths: [...paths.entries()].map(([path, requests]) => ({ path, requests })).sort(byRequestsThenName((p) => p.path)),
}))
.sort(byRequestsThenName((t) => t.tool));
return {
view: 'account',
userId: data.userId,
principal: newest?.userPrincipalName || null,
tenantId: newest?.tenantId || null,
window,
requests: byTool.reduce((sum, t) => sum + t.requests, 0),
byTool,
byDay: [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, requests]) => ({ date, requests })),
};
}
@@ -326,7 +326,7 @@ function tenantIdFromNextLink(continuation?: string): string | undefined {
* nextLink's embedded tenantId when present (it is what actually gets sent), else the explicit arg, and
* validate THAT against the managed set — which also blocks a hand-crafted continuation pointing at an
* unmanaged tenant (defense in depth on top of the backend bound). No-op for non-delegated callers and
* for offset-only continuations (geo-offset:/inv-offset:), which re-send the explicit
* for offset-only continuations (geo-offset:/inv-offset:/usage-offset:), which re-send the explicit
* tenantId every page anyway.
*/
export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined {
@@ -784,20 +784,6 @@ export interface AuthMeResponse {
whatsNewSeenAgentUtc?: string;
/** True while the caller's tenant still has to accept the Terms + DPA (its first sign-in skipped the get-started tick); the portal asks before it opens. */
dpaAcceptancePending: boolean;
/** Set while a Global Admin views a tenant as one of its members (read-only "view as"): every other field then describes that member of TenantId. Null on an ordinary sign-in, and the key is omitted. */
viewAs?: AuthMeViewAs;
}
/** The read-only "view as" behind an auth/me answer: which tenant, as which role, and who is really viewing. */
export interface AuthMeViewAs {
/** The viewed tenant (same as the response's tenantId). */
tenantId: string;
/** The viewed tenant's company name, else its domain, else its ID. */
tenantName: string;
/** The emulated tenant role: Admin, Operator or Viewer. */
role: string;
/** The Global Admin's own home tenant — telemetry is attributed there, never to the viewed tenant. */
homeTenantId: string;
}
export interface AutoResolveCpeMappingItem {
@@ -2292,6 +2278,13 @@ export interface GetGlobalAdminsResponse {
admins: GlobalAdminRow[];
}
/** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */
export interface GetGlobalMcpUsageDailyResponse {
/** Echo of the request filter; absent when the caller passed no tenantId. */
tenantId?: string;
summaries: UserUsageDailySummary[];
}
/** The all-tenant MCP usage overview of the Global Admin metrics page (GetGlobalMcpUsageOverview) over whole UTC days: tenants with their accounts and quota state, the tool ranking, the quota refusals, the per-minute peaks and the daily trend. "Calls" are tool calls (request telemetry, one MCP correlation id per call); "requests" are backend requests charged to the quota (the quota counters). What a telemetry store did not answer is empty and named in Telemetry. */
export interface GetGlobalMcpUsageOverviewResponse {
/** First day of the window (yyyyMMdd, UTC). */
@@ -2301,8 +2294,6 @@ export interface GetGlobalMcpUsageOverviewResponse {
/** False when Global Admins and Global Readers are left out of every number. */
includesPlatformAdmins: boolean;
telemetry: McpUsageTelemetryStatus;
/** The window's totals over the lists below, computed once for every consumer. */
totals: McpUsageTotalsNode;
/** One item per day of the window, oldest first. */
daily: McpUsageDayItem[];
/** Every tenant with MCP use in the window, most calls first. */
@@ -2314,6 +2305,13 @@ export interface GetGlobalMcpUsageOverviewResponse {
minuteLimits: McpUsageMinuteLimits;
}
/** Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates over the records key — its name is wire-critical. */
export interface GetGlobalMcpUsageResponse {
/** Echo of the request filter; absent when the caller passed no tenantId. */
tenantId?: string;
records: UserUsageRecord[];
}
/** Response of GET tenants/{tenantId}/graph-permissions/status: the client id of the app homed for the tenant, the transient flag, the granted Graph app roles and the per-feature verdict matrix. */
export interface GetGraphPermissionsStatusResponse {
/** ClientId of the app registration that acts for this tenant (empty string when unresolved, never null). */
@@ -2527,26 +2525,6 @@ export interface GetSessionTimeAttributionResponse {
breakdown?: SessionTimeBreakdown;
}
/** Response of GET global/metrics/tenant-activity: how every signed-up tenant used the product in the last N full UTC days, against the N days before. Built from the daily usage rows (written once per day at the first maintenance run after midnight UTC), the per-tenant counters and the portal sign-in history. Operator-only (Global Admin and Global Reader). */
export interface GetTenantActivityResponse {
windowDays: number;
/** Inclusive window start ("yyyy-MM-dd", UTC). */
windowStart: string;
/** Inclusive window end ("yyyy-MM-dd", UTC): the last day the daily usage rows cover — yesterday, or the day before while yesterday is not aggregated yet. Today is never in the window. */
usageDaysThrough: string;
/** Inclusive start of the previous window of the same length ("yyyy-MM-dd"). */
previousWindowStart: string;
/** First day the daily usage rows reach ("yyyy-MM-dd", 180 days back); a first day with sessions before it is unknown. */
rowsFrom: string;
/** False when the portal sign-in history could not be read; every portal figure is then absent. */
portalActivityAvailable: boolean;
totals: TenantActivityTotals;
/** One entry per window day, oldest first, days without enrollments included. */
daily: TenantActivityDailyItem[];
/** Every signed-up tenant: most enrollments in the window first, then most enrollments since signup. */
tenants: TenantActivityTenantItem[];
}
/** Response of GET config/fields-schema: the machine-readable tenant-config field schema for the MCP write surface. */
export interface GetTenantConfigFieldsSchemaResponse {
count: number;
@@ -3456,8 +3434,6 @@ export interface McpUsageDayItem {
export interface McpUsageMinuteItem {
tenantId: string;
principal: string;
/** How close the account came to a per-minute limit, in whole percent: the higher of its call peak against the MCP server limit and its request peak against its backend limit (the list is ordered by it); 0 when neither limit is known. */
sharePercent: number;
/** Most tool calls the account started inside 60 seconds (measured in 10-second steps). */
peakCalls: number;
peakCallsAt?: string;
@@ -3552,8 +3528,6 @@ export interface McpUsageTelemetryStatus {
/** One tenant of the overview with its organization windows and its accounts. */
export interface McpUsageTenantItem {
tenantId: string;
/** The tenant's domain from its configuration; absent when it has none or the configuration was unreadable. */
domainName?: string;
/** The organization windows (tenant plan, limits, today / this month) from the tenant's quota counters. */
quota: McpOrganizationQuotaNode;
calls: number;
@@ -3594,27 +3568,6 @@ export interface McpUsageToolItem {
routes: McpUsageRouteItem[];
}
/** The overview's totals, nested in GetGlobalMcpUsageOverviewResponse. "Calls" are tool calls, "requests" are backend requests charged to the quota; an account is a person or a service principal of one tenant. */
export interface McpUsageTotalsNode {
/** Tool calls in the window, MCP-only tools included; absent when the request telemetry did not answer. */
calls?: number;
/** Calls of tools that run in the MCP server alone (part of Calls, attributed to no account); absent without the MCP log. */
mcpOnlyCalls?: number;
/** Tools called in the window. */
tools: number;
requests: number;
tenants: number;
accounts: number;
/** Accounts that are service principals (app:<client-id>). */
servicePrincipals: number;
/** Accounts with at least one quota refusal in the window (daily or monthly, own or organization window). */
quotaHitAccounts: number;
/** Accounts with at least one per-minute refusal in the window (MCP server or backend). */
minuteRefusedAccounts: number;
/** Accounts that met any limit in the window — the union of the two above. */
limitAccounts: number;
}
export interface McpUserEntry {
upn: string;
isEnabled: boolean;
@@ -5544,106 +5497,6 @@ export interface TemplateVariable {
validation?: string;
}
/** One UTC day of the window, summed over the signed-up tenants. */
export interface TenantActivityDailyItem {
/** UTC day ("yyyy-MM-dd"). */
day: string;
enrollments: number;
/** Succeeded when the day was aggregated. */
succeeded: number;
/** Failed when the day was aggregated; the rest was still running or ended incomplete. */
failed: number;
/** Tenants with enrollments that day. */
activeTenants: number;
}
/** Tenants per lifecycle stage (TenantActivityStages). */
export interface TenantActivityStageCounts {
neverEnrolled: number;
dormant: number;
new: number;
declining: number;
active: number;
quiet: number;
}
/** One signed-up tenant. */
export interface TenantActivityTenantItem {
tenantId: string;
domainName: string;
companyName?: string;
/** Effective edition (TenantEditionNames). */
edition: string;
/** Lifecycle stage (TenantActivityStages). */
stage: string;
/** When the configuration row was created (the signup); absent on rows without the field. */
onboardedAt?: string;
/** Enrollment sessions started in the window. */
enrollments: number;
/** Enrollment sessions started in the previous window. */
enrollmentsPrevious: number;
/** Change against the previous window, whole percent; absent when the previous window had none. */
trendPercent?: number;
/** Of the window's enrollments, Succeeded when their day was aggregated. */
succeeded: number;
/** Of the window's enrollments, Failed when their day was aggregated. */
failed: number;
/** Succeeded / (Succeeded + Failed), whole percent; absent without a finished session. */
successPercent?: number;
/** Window days with enrollments. */
activeDays: number;
/** Enrollments per window day, oldest first; absent without enrollments in the window. */
dailyEnrollments?: number[];
/** First UTC day with sessions since signup ("yyyy-MM-dd"); absent when unknown — a signup before RowsFrom, or first sessions that only the counters have seen yet. */
firstEnrollmentDay?: string;
/** Latest UTC day with sessions ("yyyy-MM-dd"; a day after UsageDaysThrough when only the counters moved since); absent without any. */
lastActiveDay?: string;
/** When the tenant's counters last moved: an agent registration, a first success or an ingest batch — rarely a maintenance repair of the counter floors. Absent without counters. */
lastCounterUpdateAt?: string;
/** Enrollment sessions since signup (retention-independent counter). */
lifetimeEnrollments: number;
/** Sessions since signup that reached Succeeded. */
lifetimeSucceeded: number;
/** Events stored since signup. */
lifetimeEvents: number;
/** People with a portal visit in the window (counted in the home tenant of their token); absent without the sign-in history. */
portalUsers?: number;
/** Latest portal visit in the sign-in history (kept 90 days); absent without one or without the history. */
lastPortalVisitAt?: string;
}
/** Platform totals of the window; the tenant rows add up to them. */
export interface TenantActivityTotals {
/** Configuration rows with a domain: the signups that exist today. */
signedUpTenants: number;
/** Tenants with enrollments in the window. */
activeTenants: number;
/** Tenants with enrollments in the previous window. */
activeTenantsPrevious: number;
/** Change of the active tenants against the previous window, whole percent; absent when the previous window had none. */
activeTenantsTrendPercent?: number;
/** Enrollment sessions started in the window. */
enrollments: number;
/** Enrollment sessions started in the previous window. */
enrollmentsPrevious: number;
/** Change of the enrollments against the previous window, whole percent; absent when the previous window had none. */
enrollmentsTrendPercent?: number;
/** Of the window's enrollments, Succeeded when their day was aggregated. */
succeeded: number;
/** Of the window's enrollments, Failed when their day was aggregated. */
failed: number;
/** Succeeded / (Succeeded + Failed), whole percent; absent without a finished session. */
successPercent?: number;
/** Share of the window's enrollments that the 10 tenants with the most hold, whole percent; absent without enrollments. */
top10SharePercent?: number;
/** Tenants per lifecycle stage; every signed-up tenant is in exactly one. */
stages: TenantActivityStageCounts;
/** People with a portal visit in the window, summed over the tenants; absent without the sign-in history. */
portalUsers?: number;
/** Tenants with at least one such person; absent without the sign-in history. */
portalTenants?: number;
}
/** Response of POST tenants/{tenantId}/admins: the created tenant member. */
export interface TenantAdminCreatedResponse {
admin: TenantAdminRow;
@@ -6442,6 +6295,15 @@ export interface UserMetrics {
note: string;
}
/** Aggregated daily usage summary across endpoints (and optionally users). */
export interface UserUsageDailySummary {
date: string;
tenantId?: string;
totalRequests: number;
uniqueUsers: number;
uniqueEndpoints: number;
}
/** A single usage record: one user, one day, one endpoint. */
export interface UserUsageRecord {
userId: string;
@@ -255,10 +255,6 @@ export const API_ERROR_CODES = [
"CrossTenantAccessDenied",
"TenantSuspended",
"ClientAppNotRegistered",
"ViewAsInvalid",
"ViewAsNotAllowed",
"ViewAsTenantNotFound",
"ViewAsReadOnly",
"QuotaExceeded",
"PairingCodeInvalid",
"PairingCodeUsed",
@@ -316,17 +312,6 @@ export const API_ERROR_CODES = [
] as const;
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
/** Lifecycle stages of the tenant activity, in their test order (C# TenantActivityStages). */
export const TENANT_ACTIVITY_STAGES = [
"neverEnrolled",
"dormant",
"new",
"declining",
"active",
"quiet",
] as const;
export type TenantActivityStage = (typeof TENANT_ACTIVITY_STAGES)[number];
/** Evidence keys a matched analyze-rule condition writes, per condition source — what a {{signal.key}} token can resolve (C# RuleEvidenceKeys). */
export const ANALYZE_RULE_EVIDENCE_KEYS: Readonly<Record<string, readonly string[]>> = {
"event_type": ["eventId","sequence","timestamp","eventType","field","value","count","appId","appName","errorPatternId","errorCode","exitCode","status","timeoutHours"],
@@ -1,192 +0,0 @@
/**
* LLM-facing shaping of get_tenant_activity (GET /api/global/metrics/tenant-activity): how every signed-up customer
* tenant used the product in the last N full UTC days, against the N days before.
*
* The backend computes every figure (stages, trend and success percentages, totals) once for the GA page Tenant
* Activity and this tool. This layer only picks the fields a view shows, orders each lifecycle list, cuts every list to
* a fixed row cap with its full length stated, and turns absent wire values into explicit nulls (the wire omits them,
* which reads as "not applicable").
*
* Rows are slim on purpose: a full tenant row is about 500 characters (950 with a 90-day daily series), so the lists in
* full would pass the adminStream response cap, and the route has no paging to fall back on. A ranking row carries the
* figures of the ranking columns; a lifecycle row carries the tenant's id, domain and edition plus the figures of its
* stage.
*/
import type { GetTenantActivityResponse, TenantActivityTenantItem } from './generated/wire-types.generated.js';
import type { TenantActivityStage } from './generated/wire-vocabularies.generated.js';
export const TENANT_ACTIVITY_VIEWS = ['summary', 'ranking', 'lifecycle'] as const;
export type TenantActivityView = (typeof TENANT_ACTIVITY_VIEWS)[number];
/** The row cap of every list a view shows; each list states its full length next to the rows it keeps. */
export const TENANT_ACTIVITY_CAPS = {
/** summary: the most active tenants, the head of view=ranking. */
mostActive: 15,
/** summary: the head of each lifecycle list. */
summaryStage: 10,
/** view=ranking: the tenants with enrollments in the window. */
ranking: 300,
/** view=lifecycle without stage: each lifecycle list. */
lifecycleStage: 100,
/** view=lifecycle with stage: that one stage. */
oneStage: 500,
} as const;
/** The lifecycle lists of the summary and of view=lifecycle without stage; active and quiet are listed by stage only. */
export const LIFECYCLE_LISTS = ['new', 'declining', 'dormant', 'neverEnrolled'] as const satisfies readonly TenantActivityStage[];
type Tenant = TenantActivityTenantItem;
/** A list cut to its cap: the full length, whether rows were left out, and the rows kept. */
interface CutList<R> {
total: number;
truncated: boolean;
rows: R[];
}
function cut<R>(tenants: readonly Tenant[], cap: number, row: (t: Tenant) => R): CutList<R> {
return { total: tenants.length, truncated: tenants.length > cap, rows: tenants.slice(0, cap).map(row) };
}
/** The UTC day of a wire timestamp: the backend writes UTC ISO 8601, so the first ten characters are the day. */
const utcDay = (iso: string | undefined): string | null => (iso === undefined ? null : iso.slice(0, 10));
/** A wire day or timestamp in milliseconds; `missing` when absent or unreadable. */
function instant(iso: string | undefined, missing: number): number {
const ms = iso === undefined ? Number.NaN : Date.parse(iso);
return Number.isNaN(ms) ? missing : ms;
}
/** Descending by key; equal keys keep the backend order (Array.prototype.sort is stable). */
const descending = (key: (t: Tenant) => number) => (a: Tenant, b: Tenant): number => {
const ka = key(a);
const kb = key(b);
return ka < kb ? 1 : ka > kb ? -1 : 0;
};
/** One tenant of the ranking: the figures of the ranking columns. */
function rankingRow(t: Tenant) {
return {
tenantId: t.tenantId,
domain: t.domainName,
edition: t.edition,
stage: t.stage,
enrollments: t.enrollments,
previous: t.enrollmentsPrevious,
trendPercent: t.trendPercent ?? null,
successPercent: t.successPercent ?? null,
activeDays: t.activeDays,
lastActiveDay: t.lastActiveDay ?? null,
portalUsers: t.portalUsers ?? null,
};
}
interface StageList {
/** The figures of the stage; the row starts with the tenant's id, domain and edition. */
figures: (t: Tenant) => Record<string, string | number | null>;
/** The list order; without one, and between equal keys, the backend order stays. */
order?: (a: Tenant, b: Tenant) => number;
}
const recentActivity = (t: Tenant) => ({ enrollments: t.enrollments, lastActiveDay: t.lastActiveDay ?? null });
/** Figures and order per stage, keyed by the generated vocabulary: a stage the backend adds fails tsc here. */
const STAGE_LISTS: Record<TenantActivityStage, StageList> = {
new: {
figures: (t) => ({ onboarded: utcDay(t.onboardedAt), firstEnrollmentDay: t.firstEnrollmentDay ?? null, enrollments: t.enrollments }),
// Newest first. Without a first day the first sessions came after the last daily row: the newest of all.
order: descending((t) => instant(t.firstEnrollmentDay, Number.POSITIVE_INFINITY)),
},
declining: {
figures: (t) => ({
previous: t.enrollmentsPrevious,
enrollments: t.enrollments,
trendPercent: t.trendPercent ?? null,
lastActiveDay: t.lastActiveDay ?? null,
}),
// The largest drop against the previous window first.
order: descending((t) => t.enrollmentsPrevious - t.enrollments),
},
dormant: {
figures: (t) => ({ lifetimeEnrollments: t.lifetimeEnrollments, lastActiveDay: t.lastActiveDay ?? null }),
// The most enrollments since signup first.
order: descending((t) => t.lifetimeEnrollments),
},
neverEnrolled: {
figures: (t) => ({ onboarded: utcDay(t.onboardedAt), lastPortalVisit: utcDay(t.lastPortalVisitAt) }),
// The newest signup first; a row without a signup time last.
order: descending((t) => instant(t.onboardedAt, Number.NEGATIVE_INFINITY)),
},
active: { figures: recentActivity },
quiet: { figures: recentActivity },
};
function stageList(tenants: readonly Tenant[], stage: TenantActivityStage, cap: number) {
const { figures, order } = STAGE_LISTS[stage];
const members = tenants.filter((t) => t.stage === stage);
if (order) members.sort(order);
return cut(members, cap, (t) => ({ tenantId: t.tenantId, domain: t.domainName, edition: t.edition, ...figures(t) }));
}
function lifecycleLists(tenants: readonly Tenant[], cap: number) {
return Object.fromEntries(LIFECYCLE_LISTS.map((stage) => [stage, stageList(tenants, stage, cap)]));
}
const SUMMARY_NOTE =
`mostActive is the head of view=ranking; each stage list shows its first ${TENANT_ACTIVITY_CAPS.summaryStage} rows. ` +
`view=lifecycle lists them up to ${TENANT_ACTIVITY_CAPS.lifecycleStage} rows each, and with stage= one stage up to ` +
`${TENANT_ACTIVITY_CAPS.oneStage} rows, active and quiet included.`;
const LIFECYCLE_NOTE = `stage= lists one stage up to ${TENANT_ACTIVITY_CAPS.oneStage} rows, active and quiet included.`;
/**
* One view of the tenant activity. `stage` applies to view=lifecycle only (the tool refuses it with another view):
* that one stage's list instead of the four lifecycle lists.
*/
export function shapeTenantActivity(
data: GetTenantActivityResponse,
view: TenantActivityView,
stage?: TenantActivityStage,
): Record<string, unknown> {
const head = {
view,
window: {
days: data.windowDays,
start: data.windowStart,
through: data.usageDaysThrough,
previousStart: data.previousWindowStart,
rowsFrom: data.rowsFrom,
},
portalActivityAvailable: data.portalActivityAvailable,
};
// In the backend order: the most enrollments in the window first, then the most since signup.
const enrolled = data.tenants.filter((t) => t.enrollments > 0);
const totals = data.totals;
switch (view) {
case 'summary':
return {
...head,
totals: {
...totals,
activeTenantsTrendPercent: totals.activeTenantsTrendPercent ?? null,
enrollmentsTrendPercent: totals.enrollmentsTrendPercent ?? null,
successPercent: totals.successPercent ?? null,
top10SharePercent: totals.top10SharePercent ?? null,
portalUsers: totals.portalUsers ?? null,
portalTenants: totals.portalTenants ?? null,
},
daily: data.daily,
mostActive: cut(enrolled, TENANT_ACTIVITY_CAPS.mostActive, rankingRow),
stages: lifecycleLists(data.tenants, TENANT_ACTIVITY_CAPS.summaryStage),
note: SUMMARY_NOTE,
};
case 'ranking':
return { ...head, tenants: cut(enrolled, TENANT_ACTIVITY_CAPS.ranking, rankingRow) };
case 'lifecycle':
return stage
? { ...head, stages: { [stage]: stageList(data.tenants, stage, TENANT_ACTIVITY_CAPS.oneStage) } }
: { ...head, stages: lifecycleLists(data.tenants, TENANT_ACTIVITY_CAPS.lifecycleStage), note: LIFECYCLE_NOTE };
}
}
@@ -3,7 +3,7 @@ import { z } from 'zod';
import { apiFetch, buildQuery, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, getCallerUpnDomain, getDelegatedTenantIds, getHomeTenantId, pageSizeForCall, pickGlobalOrTenantPath, scanWithTimeoutFallback, jsonBody } from '../client.js';
import { withToolTelemetry } from '../telemetry.js';
import { getResourceContent, withEventTypeNote, RESOURCE_NAMES } from '../resource-catalog.js';
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, ObjectIdSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, ContinuationSchema, daysDescription } from './shared.js';
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, ContinuationSchema, daysDescription } from './shared.js';
import { toolError } from './error-handler.js';
import { API_BASE_URL } from '../config.js';
import { collectDeploymentState } from '../deployment-state.js';
@@ -93,8 +93,6 @@ export function buildFleetOverview(
};
}
import { shapeVerdictCalibration } from '../verdict-calibration-shape.js';
import { OVERVIEW_VIEWS, shapeAccount, shapeOrganization, shapeOverview, usageWindow } from '../api-usage-shape.js';
import { TENANT_ACTIVITY_CAPS, TENANT_ACTIVITY_VIEWS, shapeTenantActivity } from '../tenant-activity-shape.js';
import type {
AgentEfficiencyMetricsResponse,
AppMetricsResponse,
@@ -107,11 +105,11 @@ import type {
GeographicMetricsResponse,
GetAllTenantConfigurationsResponse,
GetDeviceHistoryResponse,
GetGlobalMcpUsageOverviewResponse,
GetMcpOrganizationUsageResponse,
GetGlobalMcpUsageDailyResponse,
GetGlobalMcpUsageResponse,
GetMcpUserUsageResponse,
GetMyMcpUsageResponse,
GetSessionTimeAttributionResponse,
GetTenantActivityResponse,
GetTenantConfigFieldsSchemaResponse,
GetUnmatchedSoftwareResponse,
GrowthMetricsResponse,
@@ -161,7 +159,6 @@ import {
RULE_SUBMISSION_KINDS,
RULE_SUBMISSION_STATUSES,
SESSION_STATUSES,
TENANT_ACTIVITY_STAGES,
} from '../generated/wire-vocabularies.generated.js';
/**
@@ -335,6 +332,49 @@ export function platformWindowEcho(
};
}
/**
* Page client-side over an MCP-usage payload. The mcp-usage endpoints return the
* full set in one shot (no server-side cursor): the global per-record breakdown
* alone can run to thousands of rows and overflow the inline response budget. We
* slice whichever array the payload carries ("records" for the per-user and
* global breakdowns, "summaries" for the daily view) and echo every other
* top-level field (tenantId/userId/usagePlan) verbatim. The "continuation"
* carries an integer offset (usage-offset:N); each page re-reads the same set,
* so raise pageSize to amortize round-trips on a full sweep. Mirrors the
* get_geographic_sessions client-side pager. Exported for unit testing.
*/
export function paginateUsage(
data: unknown,
pageSize: number,
continuation?: string,
): Record<string, unknown> {
if (!data || typeof data !== 'object') return { records: [], totalCount: 0, count: 0, offset: 0, nextLink: null };
const obj = data as Record<string, unknown>;
// The `satisfies` pins tie the sliced key names to the generated wire types —
// a backend rename of these wire-critical keys goes tsc-red here, not silent.
const arrayKey = Array.isArray(obj.records)
? ('records' satisfies keyof GetGlobalMcpUsageResponse & keyof GetMcpUserUsageResponse & keyof GetMyMcpUsageResponse)
: Array.isArray(obj.summaries)
? ('summaries' satisfies keyof GetGlobalMcpUsageDailyResponse)
: null;
if (!arrayKey) return obj; // unexpected shape — pass through untouched rather than mangle it
const all = obj[arrayKey] as unknown[];
const m = continuation ? /^usage-offset:(\d+)$/.exec(continuation) : null;
const offset = m ? parseInt(m[1], 10) : 0;
const slice = all.slice(offset, offset + pageSize);
const nextOffset = offset + pageSize;
const nextLink = nextOffset < all.length ? `usage-offset:${nextOffset}` : null;
const { records: _records, summaries: _summaries, ...rest } = obj;
return {
...rest,
totalCount: all.length,
count: slice.length,
offset,
[arrayKey]: slice,
nextLink,
};
}
/**
* Page client-side over a software-inventory payload. The inventory endpoints
* ({@code /api/metrics/software-inventory}, {@code /api/vulnerability/software-inventory})
@@ -343,8 +383,7 @@ export function platformWindowEcho(
* the {@code inventory} array and echo every other top-level field (success/tenantId/
* total/matched/unmatched) verbatim. The "continuation" carries an integer offset
* (inv-offset:N); each page re-reads the same set, so raise pageSize to amortize
* round-trips on a full sweep. Mirrors the get_geographic_sessions client-side pager.
* Exported for unit testing.
* round-trips on a full sweep. Mirrors {@link paginateUsage}. Exported for unit testing.
*/
export function paginateInventory(
data: unknown,
@@ -353,7 +392,7 @@ export function paginateInventory(
): Record<string, unknown> {
if (!data || typeof data !== 'object') return { inventory: [], total: 0, count: 0, offset: 0, nextLink: null };
const obj = data as Record<string, unknown>;
// The `satisfies` pin ties the sliced key to the generated wire type: renaming `inventory` on the wire goes tsc-red here.
// Same wire-key pin as paginateUsage: renaming `inventory` on the wire goes tsc-red here.
const inventoryKey = 'inventory' satisfies keyof SoftwareInventoryResponse;
if (!Array.isArray(obj[inventoryKey])) return obj; // unexpected shape — pass through untouched rather than mangle it
const all = obj[inventoryKey] as unknown[];
@@ -373,61 +412,42 @@ export function paginateInventory(
}
export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boolean = ga, delegated: boolean = false): void {
// Tool 11: get_api_usage — platform scope only (GA and Global Reader); not registered for tenant users
// (the `if (ga)` guards the whole single server.registerTool(...) statement). The shaping lives in
// api-usage-shape.ts; every figure is computed by the backend.
// Tool 11: get_api_usage — Global Admin only; not registered for normal users
// (the `if (ga)` guards the whole single server.registerTool(...) statement).
if (ga) server.registerTool(
'get_api_usage',
{
title: 'MCP Usage',
title: 'API Usage',
description:
'MCP usage across all tenants, as on the GA page MCP Usage. A call is one tool call; a request is one backend ' +
'request charged to the daily/monthly quota (requestsPerCall = cost of a call). view=summary: totals, top tools ' +
'and tenants, newest quota refusals, accounts closest to a per-minute limit, each list cut to its first rows ' +
'(full lengths in `lists`). view=tools|tenants|quotaHits|minuteLimits|daily: that list in full. A quota hit is ' +
'one refused day per account or organization and window. Platform admins count only with ' +
'includePlatformAdmins=true. view=organization + tenantId: one tenant\'s budget by account and day (platform ' +
'admins included). view=account + userId: one account\'s requests by tool, path and day. Windows are ' +
'{used, limit}: limit 0 = unlimited, used null = unreadable. `telemetry` names a store that did not answer.',
'API/MCP usage statistics: request counts per endpoint per day. Use to monitor platform usage, identify ' +
'heavy users, or debug rate limiting. userId gives one user\'s view, daily=true a compact daily summary, ' +
'neither the global per-record breakdown (thousands of verbose rows — paged client-side over the full set, ' +
'so a larger pageSize saves round-trips). dateFrom/dateTo default to the full retention window.',
inputSchema: {
view: z.enum([...OVERVIEW_VIEWS, 'organization', 'account']).optional().default('summary')
.describe('Default summary. organization needs tenantId, account needs userId.'),
days: z.coerce.number().int().min(1).max(90).optional().default(30)
.describe(daysDescription(30, 90)),
includePlatformAdmins: z.boolean().optional().default(false)
.describe('Overview views only: count Global Admins and Global Readers too.'),
tenantId: TenantGuidSchema.optional().describe('The tenant of view=organization.'),
userId: ObjectIdSchema.optional().describe('The account of view=account: its Entra object id (view=tenants lists them).'),
userId: z.string().optional().describe('Specific user object ID to query usage for'),
tenantId: z.string().optional().describe('Filter usage by tenant ID'),
dateFrom: z.string().optional().describe('Start date (YYYY-MM-DD)'),
dateTo: z.string().optional().describe('End date (YYYY-MM-DD)'),
daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'),
pageSize: z.coerce.number().int().min(1).max(2000).optional().default(50)
.describe(pageSizeDescription(50, 2000)),
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
},
annotations: READ_ONLY,
},
async (args) => withToolTelemetry('get_api_usage', args, async () => {
try {
const view = args.view;
const window = usageWindow(args.days);
if (view === 'organization') {
if (!args.tenantId) {
return toolError('get_api_usage', args, new Error(
'tenantId is required for view=organization; list_tenants resolves a tenant name to its id.'));
}
const data = await apiFetch<GetMcpOrganizationUsageResponse>(
`/api/global/metrics/mcp-usage/organization${buildQuery({ tenantId: args.tenantId, dateFrom: window.from, dateTo: window.to })}`);
return toolResultText(shapeOrganization(data), MAX_RESULT_SIZE_CHARS.adminStream);
let data: GetMcpUserUsageResponse | GetGlobalMcpUsageDailyResponse | GetGlobalMcpUsageResponse;
const params: Record<string, string | undefined> = { tenantId: args.tenantId, dateFrom: args.dateFrom, dateTo: args.dateTo };
if (args.userId) {
data = await apiFetch<GetMcpUserUsageResponse>(`/api/metrics/mcp-usage/user/${encodeURIComponent(args.userId)}${buildQuery(params)}`);
} else if (args.daily) {
data = await apiFetch<GetGlobalMcpUsageDailyResponse>(`/api/global/metrics/mcp-usage/daily${buildQuery(params)}`);
} else {
data = await apiFetch<GetGlobalMcpUsageResponse>(`/api/global/metrics/mcp-usage${buildQuery(params)}`);
}
if (view === 'account') {
if (!args.userId) {
return toolError('get_api_usage', args, new Error(
'userId (the Entra object id) is required for view=account; view=tenants lists every account with its userId.'));
}
const data = await apiFetch<GetMcpUserUsageResponse>(
`/api/metrics/mcp-usage/user/${encodeURIComponent(args.userId)}${buildQuery({ dateFrom: window.from, dateTo: window.to })}`);
return toolResultText(shapeAccount(data, window), MAX_RESULT_SIZE_CHARS.adminStream);
}
// Seven telemetry queries with a 60-s budget each run behind this route: outlast them.
const data = await apiFetch<GetGlobalMcpUsageOverviewResponse>(
`/api/global/metrics/mcp-usage/overview${buildQuery({ days: args.days, platformAdmins: args.includePlatformAdmins ? 'include' : 'exclude' })}`,
{ signal: AbortSignal.timeout(90_000) });
return toolResultText(shapeOverview(data, view), MAX_RESULT_SIZE_CHARS.adminStream);
return toolResultText(paginateUsage(data, args.pageSize, args.continuation), MAX_RESULT_SIZE_CHARS.adminStream);
} catch (error: unknown) {
return toolError('get_api_usage', args, error);
}
@@ -1087,49 +1107,6 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
})
);
// Tool: get_tenant_activity — platform scope (GA + Global Reader). Usage per signed-up customer tenant, as on
// the GA page Tenant Activity. The shaping lives in tenant-activity-shape.ts; every figure is computed by the backend.
if (ga) server.registerTool(
'get_tenant_activity',
{
title: 'Tenant Activity',
description:
'Usage per signed-up customer tenant, as on the GA page Tenant Activity: the last N full UTC days through ' +
'window.through (yesterday, or the day before until yesterday is aggregated) against the N days before; one ' +
'enrollment is one session. Each tenant has one stage: neverEnrolled (no enrollment yet), dormant (enrolled, no ' +
'activity in the last 30 days), new (first sessions in the window), declining (at least 5 enrollments in the ' +
'previous window, at most half as many now), active (enrollments in the window) or quiet (active in the last 30 ' +
'days, not in the window). Portal users are people with a portal visit in the window, counted in the home tenant ' +
'of their token: an MSP admin counts in their own tenant, a service principal never. view=summary: totals, the ' +
'daily series, the most active tenants and the head of each lifecycle list; view=ranking: the tenants with ' +
'enrollments, most first; view=lifecycle: the new, declining, dormant and neverEnrolled lists, or one stage with ' +
'stage=. Lists are cut to a fixed number of rows; total gives the full length. null = not computable, or not ' +
'readable for portal figures.',
inputSchema: {
days: z.coerce.number().int().min(1).max(90).optional()
.describe(daysDescription(30, 90)),
view: z.enum(TENANT_ACTIVITY_VIEWS).optional().default('summary')
.describe('Default summary.'),
stage: z.enum(TENANT_ACTIVITY_STAGES).optional()
.describe(`view=lifecycle only: that one stage, up to ${TENANT_ACTIVITY_CAPS.oneStage} rows (active and quiet too).`),
},
annotations: READ_ONLY,
},
async (args) => withToolTelemetry('get_tenant_activity', args, async () => {
try {
const { days, view, stage } = args;
if (stage && view !== 'lifecycle') {
return toolError('get_tenant_activity', args, new Error(
`stage applies to view=lifecycle only; re-send with view=lifecycle and stage=${stage}.`));
}
const data = await apiFetch<GetTenantActivityResponse>(`/api/global/metrics/tenant-activity${buildQuery({ days })}`);
return toolResultText(shapeTenantActivity(data, view, stage), MAX_RESULT_SIZE_CHARS.adminStream);
} catch (error: unknown) {
return toolError('get_tenant_activity', args, error);
}
})
);
// Tool 18: list_session_reports — Global Admin only; not registered for normal users.
if (ga) server.registerTool(
'list_session_reports',
@@ -92,11 +92,12 @@ export const SUMMARY_EVENT_FIELDS =
* to a different endpoint. The strict GUID pattern blocks both that path-
* traversal vector and accidental garbage inputs.
*/
const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const SessionIdSchema = z
.string()
.regex(GUID_PATTERN, 'sessionId must be a UUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")');
.regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
'sessionId must be a UUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")',
);
/**
* Read-side guard for the historical / in-flight `script_failed` rows that the agent
@@ -191,16 +192,10 @@ export function tenantIdDescription(
*/
export const TenantGuidSchema = z
.string()
.regex(GUID_PATTERN, 'tenantId must be a GUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")');
/**
* Zod validator for an account's Entra object id interpolated into a backend URL path
* (`/api/metrics/mcp-usage/user/{userId}`) — same path-traversal rationale as SessionIdSchema. It also
* turns a UPN passed by mistake into an error instead of an empty answer that reads as "no usage".
*/
export const ObjectIdSchema = z
.string()
.regex(GUID_PATTERN, 'userId must be an Entra object id (a GUID such as "e259c121-1234-4abc-9def-0123456789ab"), not a UPN');
.regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
'tenantId must be a GUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")',
);
/** Read-only query tool — no side effects, idempotent, closed-world (our backend only). */
export const READ_ONLY: ToolAnnotations = {
@@ -8,9 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -28,7 +28,7 @@
<ItemGroup>
<!-- Plan §2.8 — Newtonsoft.Json on both sides (Agent net48 + Backend net8.0) for DecisionCore DTOs. -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
@@ -430,16 +430,6 @@ namespace AutopilotMonitor.Shared
/// </summary>
public const string ClientAppNotRegistered = "ClientAppNotRegistered";
// ── view as tenant (a Global Admin reads one tenant's portal as its member sees it, read-only) ──
/// <summary>400: the view-as headers name no tenant GUID or an unknown tenant role.</summary>
public const string ViewAsInvalid = "ViewAsInvalid";
/// <summary>403: only a Global Admin may view as a tenant, and never through MCP or a foreign client app.</summary>
public const string ViewAsNotAllowed = "ViewAsNotAllowed";
/// <summary>404: the tenant to view as has no configuration row.</summary>
public const string ViewAsTenantNotFound = "ViewAsTenantNotFound";
/// <summary>403: the request would change something while viewing as a tenant.</summary>
public const string ViewAsReadOnly = "ViewAsReadOnly";
// ── MCP quota (McpQuotaEnforcementMiddleware) ──
public const string QuotaExceeded = "QuotaExceeded";
@@ -104,12 +104,6 @@ namespace AutopilotMonitor.Shared.DataAccess
/// validated tokens, which is what makes them usable as an identity-binding source.
/// </summary>
Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn);
/// <summary>
/// Every portal visit of a person since <paramref name="sinceUtc"/>, from the UserActivity sign-in history
/// (one cross-partition scan filtered on LoginAt; the table keeps 90 days). Service principals and rows
/// without any identity are left out. Not fail-soft: the caller decides how to report a missing history.
/// </summary>
Task<List<PortalVisit>> GetPortalVisitsSinceAsync(DateTime sinceUtc);
// --- Live Presence (one row per user, upserted) ---
/// <summary>Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.</summary>
@@ -21,6 +21,16 @@ namespace AutopilotMonitor.Shared.DataAccess
/// </summary>
Task<List<UserUsageRecord>> GetUsageByUserAsync(string userId, string? dateFrom = null, string? dateTo = null);
/// <summary>
/// Gets usage records for all users belonging to a tenant within an optional date range.
/// </summary>
Task<List<UserUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null);
/// <summary>
/// Gets aggregated daily usage summaries, optionally filtered by tenant.
/// </summary>
Task<List<UserUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null);
/// <summary>
/// Deletes all usage records older than the specified date (yyyyMMdd format).
/// Returns the number of records deleted.
@@ -74,4 +84,16 @@ namespace AutopilotMonitor.Shared.DataAccess
public long RequestCount { get; set; }
public DateTime LastRequestAt { get; set; }
}
/// <summary>
/// Aggregated daily usage summary across endpoints (and optionally users).
/// </summary>
public class UserUsageDailySummary
{
public string Date { get; set; } = string.Empty;
public string? TenantId { get; set; }
public long TotalRequests { get; set; }
public int UniqueUsers { get; set; }
public int UniqueEndpoints { get; set; }
}
}
@@ -1,18 +0,0 @@
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// A tenant's effective edition as the portal's plan facet names it. The display precedence is Pro conferred
/// by a managing (MSP) tenant, then the stored Pro tier, then a running trial, else Community — the order of
/// the backend's edition resolution and of the portal's <c>effectiveEdition</c>. The web derives its
/// <c>PlanEdition</c> type from this list (shared manifest), so the two cannot name different values.
/// </summary>
public static class TenantEditionNames
{
public const string Community = "community";
public const string Pro = "pro";
public const string ProTrial = "pro-trial";
public const string ProMsp = "pro-msp";
public static readonly string[] All = { Community, Pro, ProTrial, ProMsp };
}
}
@@ -48,26 +48,6 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>True while the caller's tenant still has to accept the Terms + DPA (its first sign-in skipped the get-started tick); the portal asks before it opens.</summary>
public bool DpaAcceptancePending { get; set; }
/// <summary>
/// Set while a Global Admin views a tenant as one of its members (read-only "view as"): every other field
/// then describes that member of <see cref="TenantId"/>. Null on an ordinary sign-in, and the key is omitted.
/// </summary>
public AuthMeViewAs? ViewAs { get; set; }
}
/// <summary>The read-only "view as" behind an auth/me answer: which tenant, as which role, and who is really viewing.</summary>
// Declaration order == wire order.
public class AuthMeViewAs
{
/// <summary>The viewed tenant (same as the response's tenantId).</summary>
public string TenantId { get; set; } = default!;
/// <summary>The viewed tenant's company name, else its domain, else its ID.</summary>
public string TenantName { get; set; } = default!;
/// <summary>The emulated tenant role: Admin, Operator or Viewer.</summary>
public string Role { get; set; } = default!;
/// <summary>The Global Admin's own home tenant — telemetry is attributed there, never to the viewed tenant.</summary>
public string HomeTenantId { get; set; } = default!;
}
/// <summary>Body of PUT auth/me/whats-new-seen.</summary>
@@ -1,21 +0,0 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// One portal visit of a person, read from the UserActivity sign-in history (one row per auth/me call). The
/// reader leaves out service principals (<c>app:</c> keys) and rows without any identity, so every entry is a
/// person.
/// </summary>
public class PortalVisit
{
/// <summary>Tenant of the token, lower-cased: a delegated (MSP) admin counts in their home tenant.</summary>
public string TenantId { get; set; } = string.Empty;
/// <summary>The person: the Entra object id, else the lower-cased UPN.</summary>
public string PersonKey { get; set; } = string.Empty;
/// <summary>When the visit was recorded (UTC).</summary>
public DateTime At { get; set; }
}
}
@@ -1,176 +0,0 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Lifecycle stage of a signed-up tenant in the tenant activity figures. Every tenant has exactly one: the
/// stages are tested in the order of <see cref="All"/> and the first that applies wins.
/// </summary>
public static class TenantActivityStages
{
/// <summary>No enrollment on record (the tenant's lifetime counter is zero).</summary>
public const string NeverEnrolled = "neverEnrolled";
/// <summary>Enrolled, but no activity in the last 30 days, today included — the dormant rule of the growth figures.</summary>
public const string Dormant = "dormant";
/// <summary>
/// The first day with sessions lies in the window, or the first sessions came after the last daily row.
/// Only decided for signups inside the 180 days the daily rows reach.
/// </summary>
public const string New = "new";
/// <summary>At least 5 enrollments in the previous window and at most half as many in this one.</summary>
public const string Declining = "declining";
/// <summary>Enrollments in the window.</summary>
public const string Active = "active";
/// <summary>Enrolled and active in the last 30 days, but no enrollment in the window.</summary>
public const string Quiet = "quiet";
public static readonly string[] All = { NeverEnrolled, Dormant, New, Declining, Active, Quiet };
}
// Declaration order == wire order.
/// <summary>
/// Response of <c>GET global/metrics/tenant-activity</c>: how every signed-up tenant used the product in the
/// last N full UTC days, against the N days before. Built from the daily usage rows (written once per day at
/// the first maintenance run after midnight UTC), the per-tenant counters and the portal sign-in history.
/// Operator-only (Global Admin and Global Reader).
/// </summary>
public class GetTenantActivityResponse : IApiResponse
{
public int WindowDays { get; set; }
/// <summary>Inclusive window start ("yyyy-MM-dd", UTC).</summary>
public string WindowStart { get; set; } = string.Empty;
/// <summary>
/// Inclusive window end ("yyyy-MM-dd", UTC): the last day the daily usage rows cover — yesterday, or the
/// day before while yesterday is not aggregated yet. Today is never in the window.
/// </summary>
public string UsageDaysThrough { get; set; } = string.Empty;
/// <summary>Inclusive start of the previous window of the same length ("yyyy-MM-dd").</summary>
public string PreviousWindowStart { get; set; } = string.Empty;
/// <summary>First day the daily usage rows reach ("yyyy-MM-dd", 180 days back); a first day with sessions before it is unknown.</summary>
public string RowsFrom { get; set; } = string.Empty;
/// <summary>False when the portal sign-in history could not be read; every portal figure is then absent.</summary>
public bool PortalActivityAvailable { get; set; }
public TenantActivityTotals Totals { get; set; } = default!;
/// <summary>One entry per window day, oldest first, days without enrollments included.</summary>
public IReadOnlyList<TenantActivityDailyItem> Daily { get; set; } = default!;
/// <summary>Every signed-up tenant: most enrollments in the window first, then most enrollments since signup.</summary>
public IReadOnlyList<TenantActivityTenantItem> Tenants { get; set; } = default!;
}
/// <summary>Platform totals of the window; the tenant rows add up to them.</summary>
public class TenantActivityTotals
{
/// <summary>Configuration rows with a domain: the signups that exist today.</summary>
public int SignedUpTenants { get; set; }
/// <summary>Tenants with enrollments in the window.</summary>
public int ActiveTenants { get; set; }
/// <summary>Tenants with enrollments in the previous window.</summary>
public int ActiveTenantsPrevious { get; set; }
/// <summary>Change of the active tenants against the previous window, whole percent; absent when the previous window had none.</summary>
public int? ActiveTenantsTrendPercent { get; set; }
/// <summary>Enrollment sessions started in the window.</summary>
public int Enrollments { get; set; }
/// <summary>Enrollment sessions started in the previous window.</summary>
public int EnrollmentsPrevious { get; set; }
/// <summary>Change of the enrollments against the previous window, whole percent; absent when the previous window had none.</summary>
public int? EnrollmentsTrendPercent { get; set; }
/// <summary>Of the window's enrollments, Succeeded when their day was aggregated.</summary>
public int Succeeded { get; set; }
/// <summary>Of the window's enrollments, Failed when their day was aggregated.</summary>
public int Failed { get; set; }
/// <summary>Succeeded / (Succeeded + Failed), whole percent; absent without a finished session.</summary>
public int? SuccessPercent { get; set; }
/// <summary>Share of the window's enrollments that the 10 tenants with the most hold, whole percent; absent without enrollments.</summary>
public int? Top10SharePercent { get; set; }
/// <summary>Tenants per lifecycle stage; every signed-up tenant is in exactly one.</summary>
public TenantActivityStageCounts Stages { get; set; } = default!;
/// <summary>People with a portal visit in the window, summed over the tenants; absent without the sign-in history.</summary>
public int? PortalUsers { get; set; }
/// <summary>Tenants with at least one such person; absent without the sign-in history.</summary>
public int? PortalTenants { get; set; }
}
/// <summary>Tenants per lifecycle stage (<see cref="TenantActivityStages"/>).</summary>
public class TenantActivityStageCounts
{
public int NeverEnrolled { get; set; }
public int Dormant { get; set; }
public int New { get; set; }
public int Declining { get; set; }
public int Active { get; set; }
public int Quiet { get; set; }
}
/// <summary>One UTC day of the window, summed over the signed-up tenants.</summary>
public class TenantActivityDailyItem
{
/// <summary>UTC day ("yyyy-MM-dd").</summary>
public string Day { get; set; } = string.Empty;
public int Enrollments { get; set; }
/// <summary>Succeeded when the day was aggregated.</summary>
public int Succeeded { get; set; }
/// <summary>Failed when the day was aggregated; the rest was still running or ended incomplete.</summary>
public int Failed { get; set; }
/// <summary>Tenants with enrollments that day.</summary>
public int ActiveTenants { get; set; }
}
/// <summary>One signed-up tenant.</summary>
public class TenantActivityTenantItem
{
public string TenantId { get; set; } = string.Empty;
public string DomainName { get; set; } = string.Empty;
public string? CompanyName { get; set; }
/// <summary>Effective edition (<see cref="TenantEditionNames"/>).</summary>
public string Edition { get; set; } = string.Empty;
/// <summary>Lifecycle stage (<see cref="TenantActivityStages"/>).</summary>
public string Stage { get; set; } = string.Empty;
/// <summary>When the configuration row was created (the signup); absent on rows without the field.</summary>
public DateTime? OnboardedAt { get; set; }
/// <summary>Enrollment sessions started in the window.</summary>
public int Enrollments { get; set; }
/// <summary>Enrollment sessions started in the previous window.</summary>
public int EnrollmentsPrevious { get; set; }
/// <summary>Change against the previous window, whole percent; absent when the previous window had none.</summary>
public int? TrendPercent { get; set; }
/// <summary>Of the window's enrollments, Succeeded when their day was aggregated.</summary>
public int Succeeded { get; set; }
/// <summary>Of the window's enrollments, Failed when their day was aggregated.</summary>
public int Failed { get; set; }
/// <summary>Succeeded / (Succeeded + Failed), whole percent; absent without a finished session.</summary>
public int? SuccessPercent { get; set; }
/// <summary>Window days with enrollments.</summary>
public int ActiveDays { get; set; }
/// <summary>Enrollments per window day, oldest first; absent without enrollments in the window.</summary>
public int[]? DailyEnrollments { get; set; }
/// <summary>
/// First UTC day with sessions since signup ("yyyy-MM-dd"); absent when unknown — a signup before
/// <see cref="GetTenantActivityResponse.RowsFrom"/>, or first sessions that only the counters have seen yet.
/// </summary>
public string? FirstEnrollmentDay { get; set; }
/// <summary>Latest UTC day with sessions ("yyyy-MM-dd"; a day after UsageDaysThrough when only the counters moved since); absent without any.</summary>
public string? LastActiveDay { get; set; }
/// <summary>
/// When the tenant's counters last moved: an agent registration, a first success or an ingest batch — rarely
/// a maintenance repair of the counter floors. Absent without counters.
/// </summary>
public DateTime? LastCounterUpdateAt { get; set; }
/// <summary>Enrollment sessions since signup (retention-independent counter).</summary>
public long LifetimeEnrollments { get; set; }
/// <summary>Sessions since signup that reached Succeeded.</summary>
public long LifetimeSucceeded { get; set; }
/// <summary>Events stored since signup.</summary>
public long LifetimeEvents { get; set; }
/// <summary>People with a portal visit in the window (counted in the home tenant of their token); absent without the sign-in history.</summary>
public int? PortalUsers { get; set; }
/// <summary>Latest portal visit in the sign-in history (kept 90 days); absent without one or without the history.</summary>
public DateTime? LastPortalVisitAt { get; set; }
}
}
@@ -16,11 +16,5 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Sessions the tenant started that day.</summary>
public int SessionsTotal { get; set; }
/// <summary>Of those, Succeeded when the day was aggregated (the first maintenance run after midnight UTC).</summary>
public int SessionsSucceeded { get; set; }
/// <summary>Of those, Failed when the day was aggregated; a session still running then counts in neither.</summary>
public int SessionsFailed { get; set; }
}
}

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