mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-07 02:31:06 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46880b58e7 | |||
| ee96ff2d23 | |||
| 4275c13dfc | |||
| 1616e33c14 | |||
| 0b39455d4e | |||
| 48baffe753 | |||
| f6f88161f9 | |||
| d0a525ecbd | |||
| 257f403949 | |||
| 7cbcb6bc4c | |||
| f43462567e | |||
| b5ac4651a0 | |||
| 3bc8b87a19 | |||
| 950b3cc679 | |||
| 0559919a9e | |||
| e8f8b876e3 | |||
| 83afd4f1c9 | |||
| d695c9b8c4 | |||
| 1ee7c48c92 | |||
| 98ac414208 | |||
| 5153e90e7c | |||
| 5b139f90e7 | |||
| 05d21cb240 | |||
| 5cc69a696a | |||
| fd3ce44c0c | |||
| 0b48b1ce63 | |||
| 5cb536c46e | |||
| 3f97a418d4 | |||
| cdd44d152a | |||
| d26d877989 | |||
| 59959c663e | |||
| ed2e3fd96c | |||
| d35edeaf9f | |||
| 21b802a028 | |||
| ae258cd9a7 |
+1
-1
@@ -30,7 +30,7 @@
|
||||
- Stage explicit file lists; never `git add -A` or a directory. Pushing is the user's call.
|
||||
- New API responses need a typed DTO plus the parity fact. MCP vocabularies are generated from the wire types, never retyped.
|
||||
- Every route belongs in the route policy catalog; every new event type goes into the MCP resource catalog.
|
||||
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
|
||||
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `.github/zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
|
||||
- The about, terms and privacy pages under `src/Web/autopilot-monitor-web/app/` state facts customers rely on: verify every claim against the code before writing it and update them in the same change as the fact they describe.
|
||||
|
||||
## Code Quality
|
||||
|
||||
+1
-1
Submodule .claude/commands updated: bba6d21544...935f869d09
@@ -24,6 +24,11 @@ updates:
|
||||
dotnet-security:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
# Application Insights 3 is the OpenTelemetry rewrite (no telemetry processors, another sampling
|
||||
# model) and the Functions worker integration still builds on 2.x: a planned migration, not a bump.
|
||||
ignore:
|
||||
- dependency-name: 'Microsoft.ApplicationInsights.WorkerService'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/Web/autopilot-monitor-web
|
||||
@@ -101,8 +106,9 @@ updates:
|
||||
# SWA CLI for the deploy job of deploy-web.yml, which installs it from this lockfile next to
|
||||
# the production deploy token: a version reaches that job only through a reviewed bump here.
|
||||
# deploy-web watches this folder, so a merged bump redeploys and proves the new CLI at once.
|
||||
# Not under .github/: there Dependabot's update PR was refused as an unauthorized change.
|
||||
- package-ecosystem: npm
|
||||
directory: /.github/tools/swa-cli
|
||||
directory: /tools/swa-cli
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "autopilot-monitor-swa-cli",
|
||||
"private": true,
|
||||
"description": "Azure Static Web Apps CLI for the deploy job of deploy-web.yml - installed from this lockfile with --ignore-scripts, because that job holds the production deploy token.",
|
||||
"devDependencies": {
|
||||
"@azure/static-web-apps-cli": "2.0.10"
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ name: Workflow gates
|
||||
# version comment (stricter than zizmor's unpinned-uses audit).
|
||||
# * zizmor -- static analysis of every workflow (permissions, credential
|
||||
# persistence, template injection, ...). Configuration and the
|
||||
# reasoned ignores live in zizmor.yml at the repository root; the
|
||||
# same run is expected locally before a workflow change is committed.
|
||||
# reasoned ignores live in .github/zizmor.yml, which zizmor finds on
|
||||
# its own; the same run is expected locally before a workflow change
|
||||
# is committed.
|
||||
# * hosts -- no workflow or .github/scripts helper spells out a host; every one
|
||||
# comes from .github/scripts/Get-Endpoints.ps1, CI's member of the URL
|
||||
# registry (the C#, web and MCP registries have guard tests of their own).
|
||||
@@ -39,10 +40,10 @@ name: Workflow gates
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -24,7 +24,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -56,7 +56,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -48,7 +48,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Deploy Web (Static Web App)
|
||||
|
||||
# Build here, upload with the SWA CLI from its own lockfile (.github/tools/swa-cli). No
|
||||
# Build here, upload with the SWA CLI from its own lockfile (tools/swa-cli). No
|
||||
# third-party action sees the deploy token, the build runs on the same path ci.yml tests
|
||||
# (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the action's container,
|
||||
# and the exported site is attested like the agent ZIP and the MSI.
|
||||
@@ -19,7 +19,7 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- '.github/tools/swa-cli/**'
|
||||
- 'tools/swa-cli/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
# --no-use-keychain keeps unloaded. Before the artifact arrives, so nothing from the
|
||||
# build zone can touch the install.
|
||||
- name: Install the SWA CLI from its lockfile
|
||||
working-directory: .github/tools/swa-cli
|
||||
working-directory: tools/swa-cli
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Download build artifact
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
env:
|
||||
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
.github/tools/swa-cli/node_modules/.bin/swa deploy \
|
||||
tools/swa-cli/node_modules/.bin/swa deploy \
|
||||
--app-location "$WEB_DIR" \
|
||||
--output-location out \
|
||||
--swa-config-location "$WEB_DIR" \
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# The three submodules are private. "update = none" makes recursive clones skip them (the
|
||||
# public, Dependabot's NuGet updater); a clone with access sets
|
||||
# submodule.<name>.update = checkout once, or runs git submodule update --init --checkout.
|
||||
[submodule ".claude/commands"]
|
||||
path = .claude/commands
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-dotclaude.git
|
||||
update = none
|
||||
[submodule "docs"]
|
||||
path = internal
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-internal.git
|
||||
update = none
|
||||
[submodule "infra"]
|
||||
path = infra
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-infra.git
|
||||
update = none
|
||||
|
||||
+1
-1
Submodule internal updated: 461040a828...4038bbcdf3
+3
-3
@@ -18,9 +18,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<Import Project="V2Isolation.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Plan §2.16 strict separation rule — build-time enforcement. -->
|
||||
|
||||
+3
-3
@@ -13,9 +13,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -71,7 +72,7 @@ public class AppInsightsQueryFunctionTests
|
||||
"rows":[["2026-09-04T10:00:00Z",42,"{\"TenantId\":\"t1\"}",null]]}]}
|
||||
""";
|
||||
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody(body);
|
||||
|
||||
Assert.Null(partial);
|
||||
var table = Assert.Single(tables);
|
||||
@@ -97,7 +98,7 @@ public class AppInsightsQueryFunctionTests
|
||||
"error":{"code":"PartialError","message":"There were some errors when processing your query.","innererror":{"code":"E_QUERY_RESULT_SET_TOO_LARGE","message":"Query result set has exceeded the internal record count limit 500000."}}}
|
||||
""";
|
||||
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody(body);
|
||||
|
||||
Assert.Single(tables);
|
||||
Assert.Equal("E_QUERY_RESULT_SET_TOO_LARGE: Query result set has exceeded the internal record count limit 500000.", partial);
|
||||
@@ -106,7 +107,7 @@ public class AppInsightsQueryFunctionTests
|
||||
[Fact]
|
||||
public void Empty_or_shapeless_body_yields_no_tables_and_no_partial()
|
||||
{
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody("{}");
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody("{}");
|
||||
Assert.Empty(tables);
|
||||
Assert.Null(partial);
|
||||
}
|
||||
@@ -115,16 +116,16 @@ public class AppInsightsQueryFunctionTests
|
||||
[Fact]
|
||||
public void Error_extraction_prefers_innererror_and_tolerates_non_json()
|
||||
{
|
||||
var (msg, code) = AppInsightsQueryFunction.ExtractError(
|
||||
var (msg, code) = LogQueryExecutor.ExtractError(
|
||||
"""{"error":{"message":"The request had some invalid properties","code":"BadArgumentError","innererror":{"code":"SyntaxError","message":"Query could not be parsed at 'foo'"}}}""");
|
||||
Assert.Equal("SyntaxError", code);
|
||||
Assert.Equal("Query could not be parsed at 'foo'", msg);
|
||||
|
||||
var (outerMsg, outerCode) = AppInsightsQueryFunction.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
|
||||
var (outerMsg, outerCode) = LogQueryExecutor.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
|
||||
Assert.Equal("InsufficientAccessError", outerCode);
|
||||
Assert.Equal("Forbidden", outerMsg);
|
||||
|
||||
var (plainMsg, plainCode) = AppInsightsQueryFunction.ExtractError("<html>502 Bad Gateway</html>");
|
||||
var (plainMsg, plainCode) = LogQueryExecutor.ExtractError("<html>502 Bad Gateway</html>");
|
||||
Assert.Null(plainCode);
|
||||
Assert.Equal("Telemetry store query failed", plainMsg);
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ public class AuthFunctionSideEffectTests
|
||||
return BuildRequest(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")));
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal)
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal, RequestContext? requestContext = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
@@ -190,9 +190,11 @@ public class AuthFunctionSideEffectTests
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
|
||||
if (requestContext != null)
|
||||
items[RequestContext.ItemsKey] = requestContext;
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(
|
||||
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
@@ -1093,4 +1095,96 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, push.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")].Status);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetCurrentUser under view-as: a Global Admin reads another tenant as its member
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const string ViewedTenantId = "cccccccc-1111-2222-3333-444444444444";
|
||||
|
||||
/// <summary>The context PolicyEnforcementMiddleware builds for an admitted view-as request.</summary>
|
||||
private static RequestContext ViewingAs(string role) => new()
|
||||
{
|
||||
TenantId = ViewedTenantId,
|
||||
TargetTenantId = ViewedTenantId,
|
||||
UserPrincipalName = Upn,
|
||||
UserRole = role,
|
||||
IsTenantAdmin = role == Constants.TenantRoles.Admin,
|
||||
ViewAs = new ViewAsContext(TenantId, role),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUser_ViewAs_AnswersAsTheViewedTenantsMember_WithoutAnySignInSideEffect()
|
||||
{
|
||||
// The viewed tenant asks for the Terms + DPA; the caller's home row still lacks its domain — exactly the
|
||||
// state in which an ordinary sign-in would seed it, onboard, and record a login.
|
||||
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
|
||||
viewed.DomainName = "fabrikam.example";
|
||||
viewed.CompanyName = "Fabrikam";
|
||||
viewed.DpaAcceptancePending = true;
|
||||
var home = DefaultConfig();
|
||||
home.DomainName = null!;
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((home, true));
|
||||
var viewedStore = _tenantConfigMock.StubWrites(ViewedTenantId, row: viewed);
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(true);
|
||||
_mcpUserMock
|
||||
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
|
||||
var seen = new DateTime(2026, 11, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen(platformUtc: seen));
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Viewer));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await ReadBodyAsync(response);
|
||||
Assert.Contains($"\"tenantId\":\"{ViewedTenantId}\"", body);
|
||||
Assert.Contains("\"isGlobalAdmin\":false", body);
|
||||
Assert.Contains("\"isGlobalReader\":false", body);
|
||||
Assert.Contains("\"isDelegated\":false", body);
|
||||
Assert.Contains("\"role\":\"Viewer\"", body);
|
||||
Assert.Contains("\"isTenantAdmin\":false", body);
|
||||
Assert.Contains("\"dpaAcceptancePending\":true", body);
|
||||
Assert.Contains("\"whatsNewSeenPlatformUtc\":\"2026-11-01T00:00:00Z\"", body);
|
||||
Assert.Contains(
|
||||
$"\"viewAs\":{{\"tenantId\":\"{ViewedTenantId}\",\"tenantName\":\"Fabrikam\",\"role\":\"Viewer\",\"homeTenantId\":\"{TenantId}\"}}",
|
||||
body);
|
||||
|
||||
// No sign-in side effect in either tenant, and no role lookup of the caller at all.
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Empty(viewedStore.Writes);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_tenantAdminsMock.Verify(x => x.GetMembershipSnapshotAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_metricsRepoMock.Verify(x => x.RecordUserLoginAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_globalAdminMock.Verify(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUser_ViewAs_AnInactiveTenant_ShowsItsActivationGate()
|
||||
{
|
||||
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
|
||||
viewed.DomainName = "fabrikam.example";
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((DefaultConfig(), true));
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(false);
|
||||
_mcpUserMock
|
||||
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
|
||||
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen());
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Admin));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
// What the tenant's own admin gets while the tenant waits for activation — the Global Admin bypass is gone.
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Contains("PendingActivation", await ReadBodyAsync(response));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -218,10 +219,12 @@ public class AutopilotDeviceValidatorTests
|
||||
|
||||
private const string Upn = "first.last@example.invalid";
|
||||
|
||||
private static JsonElement Device(string json) => JsonSerializer.Deserialize<JsonElement>(json);
|
||||
|
||||
[Fact]
|
||||
public void ReadRegistration_MapsTheKeptFields_AndKeepsOnlyWhetherAUserIsAssigned()
|
||||
{
|
||||
var device = Newtonsoft.Json.Linq.JObject.Parse(
|
||||
var device = Device(
|
||||
"{\"id\":\"ap-1\",\"groupTag\":\" TEST \",\"purchaseOrderIdentifier\":\"PO-7\",\"displayName\":\"LAB-01\"," +
|
||||
$"\"userPrincipalName\":\"{Upn}\",\"addressableUserName\":\"First Last\",\"productKey\":\"AAAAA-BBBBB\"}}");
|
||||
|
||||
@@ -241,7 +244,7 @@ public class AutopilotDeviceValidatorTests
|
||||
[Fact]
|
||||
public void ReadRegistration_TreatsBlankMissingAndNonTextValuesAsAbsent()
|
||||
{
|
||||
var device = Newtonsoft.Json.Linq.JObject.Parse(
|
||||
var device = Device(
|
||||
"{\"id\":\"ap-1\",\"groupTag\":\" \",\"purchaseOrderIdentifier\":null,\"displayName\":42,\"userPrincipalName\":\"\"}");
|
||||
|
||||
var registration = AutopilotDeviceValidator.ReadRegistration(device);
|
||||
@@ -256,7 +259,7 @@ public class AutopilotDeviceValidatorTests
|
||||
public void ReadRegistration_CutsOverlongText()
|
||||
{
|
||||
var longTag = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 50);
|
||||
var device = Newtonsoft.Json.Linq.JObject.Parse($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
|
||||
var device = Device($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
|
||||
|
||||
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, AutopilotDeviceValidator.ReadRegistration(device).GroupTag!.Length);
|
||||
}
|
||||
|
||||
+4
-4
@@ -13,10 +13,10 @@
|
||||
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
|
||||
which would otherwise surface NU1903/NU1904 advisories here. -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="Moq" Version="4.21.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -101,9 +101,8 @@ public class DeviceAssociationValidatorTests
|
||||
Assert.Equal("preassociated", result.AssociationState);
|
||||
Assert.Equal("00000000-0000-0000-0000-000000000000", result.DevicePreparationPolicyId);
|
||||
Assert.Equal("admin@contoso.com", result.PreAssociatedByUserPrincipalName);
|
||||
// Newtonsoft.Json yields "" for JSON null when calling .ToString() on the JToken;
|
||||
// downstream telemetry uses IsNullOrEmpty so both null and "" surface as "absent".
|
||||
Assert.True(string.IsNullOrEmpty(result.AssignedToUserPrincipalName));
|
||||
// JSON null is absence (D-420); the former Newtonsoft reader turned it into "".
|
||||
Assert.Null(result.AssignedToUserPrincipalName);
|
||||
Assert.NotNull(result.PreAssociationDateTime);
|
||||
Assert.Null(result.AssociationDateTime); // Graph "0001-01-01" → null (unset DateTimeOffset sentinel)
|
||||
}
|
||||
|
||||
+30
@@ -130,6 +130,36 @@ public class ScriptDisplayNameResolverTests
|
||||
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"id\":\"nameless\",\"displayName\":null}")]
|
||||
[InlineData("{\"id\":\"nameless\",\"displayName\":42}")]
|
||||
[InlineData("{\"id\":\"nameless\"}")]
|
||||
public async Task PerId_script_without_a_name_is_cached_and_not_fetched_again(string body)
|
||||
{
|
||||
// JSON null and a non-text name read as absent (D-420); the answer is still final, so the second view
|
||||
// is a cache hit instead of another Graph call.
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Repo.Meta[(TenantId, ScriptKind.Platform)] = new ScriptNameCacheMeta
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Kind = ScriptKind.Platform,
|
||||
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
|
||||
};
|
||||
var r = new ScriptRef(ScriptKind.Platform, "nameless");
|
||||
fix.Http.When("deviceManagementScripts/nameless", HttpStatusCode.OK, body);
|
||||
|
||||
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(first[r]);
|
||||
Assert.Null(second[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
|
||||
Assert.False(entry.IsNotFound);
|
||||
Assert.Null(entry.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_refs_returns_empty_dict()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The contract of the Graph and Entra token-endpoint response parsers (D-420): what every realistic body,
|
||||
/// and every malformed one, yields. Written against the Newtonsoft parsers before their move to
|
||||
/// System.Text.Json and kept as their specification. Tests whose name ends in _D1 to _D7 pin a
|
||||
/// deliberate difference of that move (listed in D-420).
|
||||
/// </summary>
|
||||
public class GraphResponseParsingTests
|
||||
{
|
||||
private const string Serial = "PF3XKQ7";
|
||||
private const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
|
||||
|
||||
// ── GraphTokenService.ReadTokenResponse ─────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"access_token":"t","expires_in":3599}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t","expires_in":"3599"}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t","expires_in":" 3599 "}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t","expires_in":"+3599"}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t",/*c*/"expires_in":3599,}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t"}""", null)]
|
||||
[InlineData("""{"access_token":"t","expires_in":null}""", null)]
|
||||
public void Token_response_reads_the_token_and_its_lifetime(string body, long? expiresIn)
|
||||
{
|
||||
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
|
||||
Assert.Equal("t", token);
|
||||
Assert.Equal(expiresIn, lifetime);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{"access_token":""}""")]
|
||||
[InlineData("""{"access_token":null,"expires_in":"abc"}""")]
|
||||
public void Token_response_without_a_token_yields_none_and_ignores_the_lifetime(string body)
|
||||
{
|
||||
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
|
||||
Assert.True(string.IsNullOrEmpty(token));
|
||||
Assert.Null(lifetime);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<html>busy</html>")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("""{"access_token":"t"} x""")]
|
||||
public void Token_response_that_is_not_one_object_throws(string body)
|
||||
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse(body));
|
||||
|
||||
// D5: an expires_in that is not a whole number counts as absent; the token stays usable with the
|
||||
// default lifetime. Newtonsoft threw on the first five and coerced the last two (3600, 1).
|
||||
[Theory]
|
||||
[InlineData("\"abc\"")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("99999999999999999999")]
|
||||
[InlineData("3599.5")]
|
||||
[InlineData("true")]
|
||||
public void Token_response_with_an_unusable_lifetime_keeps_the_token_D5(string expiresIn)
|
||||
{
|
||||
var (token, lifetime) = GraphTokenService.ReadTokenResponse($$"""{"access_token":"t","expires_in":{{expiresIn}}}""");
|
||||
Assert.Equal("t", token);
|
||||
Assert.Null(lifetime);
|
||||
}
|
||||
|
||||
// ── EntraTokenRejection.Parse ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"error_codes":[7000229],"error_description":"AADSTS7000229: missing"}""", "AADSTS7000229")]
|
||||
[InlineData("""{"error_codes":["7000229",53003]}""", "AADSTS53003")]
|
||||
[InlineData("""{"error_codes":[7000229.0],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
|
||||
[InlineData("""{"error_codes":7000229,"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
|
||||
[InlineData("""{"error_codes":[],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
|
||||
[InlineData("""{"error_codes":[1],"error_codes":[7000229]}""", "AADSTS7000229")]
|
||||
[InlineData("""{"error_codes":[7000229,],/*c*/}""", "AADSTS7000229")]
|
||||
public void Entra_rejection_reads_the_first_integer_code_then_the_description(string body, string code)
|
||||
=> Assert.Equal(code, EntraTokenRejection.Parse(400, body).ErrorCode);
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("\"AADSTS7000229\"")]
|
||||
[InlineData("42")]
|
||||
[InlineData("[1,2]")]
|
||||
[InlineData("<html>busy</html>")]
|
||||
[InlineData("""{"error_description":null}""")]
|
||||
[InlineData("""{"error_description":42}""")]
|
||||
[InlineData("""{"error_codes":[7000229]} x""")]
|
||||
public void Entra_rejection_without_a_readable_object_keeps_only_the_status(string? body)
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(400, body);
|
||||
Assert.Equal(400, rejection.StatusCode);
|
||||
Assert.Null(rejection.ErrorCode);
|
||||
Assert.Null(rejection.Description);
|
||||
}
|
||||
|
||||
// D3: a description is kept as sent, even when it looks like a date (Newtonsoft dropped it).
|
||||
[Fact]
|
||||
public void Entra_rejection_keeps_a_date_shaped_description_D3()
|
||||
=> Assert.Equal("2026-11-01T08:00:00Z", EntraTokenRejection.Parse(400, """{"error_description":"2026-11-01T08:00:00Z"}""").Description);
|
||||
|
||||
// D6: an error code beyond Int64 is skipped like any other unusable entry (Newtonsoft threw).
|
||||
[Fact]
|
||||
public void Entra_rejection_skips_an_out_of_range_code_D6()
|
||||
=> Assert.Equal("AADSTS53003", EntraTokenRejection.Parse(400, """{"error_codes":[99999999999999999999,53003]}""").ErrorCode);
|
||||
|
||||
// ── AutopilotDeviceValidator.FindRegistration ───────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{"value":"oops"}""")]
|
||||
[InlineData("""{"value":[]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":null}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
|
||||
public void Autopilot_list_without_the_serial_is_not_registered(string body)
|
||||
=> Assert.Null(AutopilotDeviceValidator.FindRegistration(body, Serial));
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("""{"value":[]} x""")]
|
||||
public void Autopilot_list_that_is_not_one_object_throws(string body)
|
||||
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(body, Serial));
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[null,{"id":"ap-1","serialNumber":"PF3XKQ7"}]}""", Serial));
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_matches_the_trimmed_serial_case_insensitively_and_stops_there()
|
||||
{
|
||||
var registration = AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[{"id":"ap-1","serialNumber":" pf3xkq7 ","groupTag":" Sales ","purchaseOrderIdentifier":"PO-7","displayName":"KIOSK-1","userPrincipalName":"u@contoso.com"},null]}""",
|
||||
Serial);
|
||||
|
||||
Assert.NotNull(registration);
|
||||
Assert.Equal("ap-1", registration!.DeviceId);
|
||||
Assert.Equal("Sales", registration.GroupTag);
|
||||
Assert.Equal("PO-7", registration.PurchaseOrderId);
|
||||
Assert.Equal("KIOSK-1", registration.DisplayName);
|
||||
Assert.True(registration.UserAssigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_registration_drops_blank_and_non_text_values_and_cuts_long_text()
|
||||
{
|
||||
var longText = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 10);
|
||||
var registration = AutopilotDeviceValidator.FindRegistration(
|
||||
$$"""{"value":[{"id":" ","serialNumber":"PF3XKQ7","groupTag":true,"purchaseOrderIdentifier":{},"displayName":"{{longText}}","userPrincipalName":null}]}""",
|
||||
Serial);
|
||||
|
||||
Assert.NotNull(registration);
|
||||
Assert.Null(registration!.DeviceId);
|
||||
Assert.Null(registration.GroupTag);
|
||||
Assert.Null(registration.PurchaseOrderId);
|
||||
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, registration.DisplayName!.Length);
|
||||
Assert.False(registration.UserAssigned);
|
||||
}
|
||||
|
||||
// D4: a serial that is not text never matches (Newtonsoft matched through its text).
|
||||
[Fact]
|
||||
public void Autopilot_list_with_a_numeric_serial_does_not_match_D4()
|
||||
=> Assert.Null(AutopilotDeviceValidator.FindRegistration("""{"value":[{"id":"ap-1","serialNumber":12345}]}""", "12345"));
|
||||
|
||||
// D3: registration text is kept as sent, even when it looks like a date (Newtonsoft dropped it).
|
||||
[Fact]
|
||||
public void Autopilot_registration_keeps_date_shaped_text_D3()
|
||||
{
|
||||
var registration = AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7","groupTag":"2026-01-01T00:00:00Z","displayName":"/Date(1713096021000)/"}]}""",
|
||||
Serial);
|
||||
|
||||
Assert.Equal("2026-01-01T00:00:00Z", registration!.GroupTag);
|
||||
Assert.Equal("/Date(1713096021000)/", registration.DisplayName);
|
||||
}
|
||||
|
||||
// ── CorporateIdentifierValidator.ReadIdentityPage ───────────────────────────
|
||||
|
||||
private static readonly string CorporateId =
|
||||
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "")]
|
||||
[InlineData("null", "")]
|
||||
[InlineData("{}", "")]
|
||||
[InlineData("""{"value":null}""", "")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":""}""", "")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":null}""", "")]
|
||||
[InlineData("""{"value":{},"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""", "https://graph.microsoft.com/beta/next")]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"1234-5678"}]}""", "")]
|
||||
public void Corporate_page_without_the_identifier_yields_the_next_url(string body, string nextUrl)
|
||||
=> Assert.Equal((false, nextUrl), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}],"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""")]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"MANUFACTURERMODELSERIAL","importedDeviceIdentifier":"MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"}]}""")]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":null},{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""")]
|
||||
public void Corporate_page_with_the_identifier_is_found(string body)
|
||||
=> Assert.Equal((true, string.Empty), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json")]
|
||||
[InlineData("[]")]
|
||||
public void Corporate_page_that_is_not_one_object_throws(string body)
|
||||
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
|
||||
|
||||
[Fact]
|
||||
public void Corporate_page_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => CorporateIdentifierValidator.ReadIdentityPage(
|
||||
"""{"value":[null,{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""",
|
||||
CorporateId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("5")]
|
||||
[InlineData("true")]
|
||||
[InlineData("{}")]
|
||||
public void Corporate_page_with_a_non_text_next_link_never_yields_a_page_to_follow(string nextLink)
|
||||
{
|
||||
// The scan must stop as a failure: either the read throws or the url cannot be requested.
|
||||
try
|
||||
{
|
||||
var (found, nextUrl) = CorporateIdentifierValidator.ReadIdentityPage($$"""{"value":[],"@odata.nextLink":{{nextLink}}}""", CorporateId);
|
||||
Assert.False(found);
|
||||
Assert.False(string.IsNullOrEmpty(nextUrl));
|
||||
Assert.False(Uri.IsWellFormedUriString(nextUrl, UriKind.Absolute));
|
||||
}
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
|
||||
{
|
||||
// Throwing ends the scan as transient, like an unusable url does.
|
||||
}
|
||||
}
|
||||
|
||||
// D4: an identifier that is not text never matches (Newtonsoft matched an array through its text).
|
||||
[Fact]
|
||||
public void Corporate_page_with_an_array_identifier_does_not_match_D4()
|
||||
=> Assert.False(CorporateIdentifierValidator.ReadIdentityPage(
|
||||
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":["Microsoft Corporation,Virtual Machine,1234-5678"]}]}""",
|
||||
CorporateId).Found);
|
||||
|
||||
// ── DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse ─────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("{} x")]
|
||||
[InlineData("""{"value":null}""")]
|
||||
[InlineData("""{"value":{}}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
|
||||
public void Association_list_without_the_serial_is_not_found(string body)
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Association_list_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[null,{"serialNumber":"PF3XKQ7"}]}""", Serial));
|
||||
|
||||
[Fact]
|
||||
public void Association_list_reads_the_matching_device_and_stops_there()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":" pf3xkq7 ","associationState":"associated","devicePreparationPolicyId":"pol-1","preassociatedByUserPrincipalName":"a@contoso.com","assignedToUserPrincipalName":"u@contoso.com","managedDeviceId":"md-1","preassociationDateTime":"2026-05-08T12:16:49Z"},null]}""",
|
||||
Serial);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("associated", result.AssociationState);
|
||||
Assert.Equal("pol-1", result.DevicePreparationPolicyId);
|
||||
Assert.Equal("a@contoso.com", result.PreAssociatedByUserPrincipalName);
|
||||
Assert.Equal("u@contoso.com", result.AssignedToUserPrincipalName);
|
||||
Assert.Equal("md-1", result.ManagedDeviceId);
|
||||
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc), result.PreAssociationDateTime);
|
||||
Assert.Equal(DateTimeKind.Utc, result.PreAssociationDateTime!.Value.Kind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7"}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":null}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":""}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"0001-01-01T00:00:00Z"}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"not-a-date"}]}""")]
|
||||
public void Association_date_that_is_unset_reads_as_null(string body)
|
||||
=> Assert.Null(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial).AssociationDateTime);
|
||||
|
||||
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void Association_fields_that_are_json_null_read_as_null_D1()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":"PF3XKQ7","associationState":null,"assignedToUserPrincipalName":null,"managedDeviceId":null}]}""",
|
||||
Serial);
|
||||
Assert.Null(result.AssociationState);
|
||||
Assert.Null(result.AssignedToUserPrincipalName);
|
||||
Assert.Null(result.ManagedDeviceId);
|
||||
}
|
||||
|
||||
// D4: text whose escapes are no valid UTF-16 reads as absent, in the serial of another entry and in a field
|
||||
// of the match, instead of failing the whole lookup (Newtonsoft read it into a broken string).
|
||||
[Fact]
|
||||
public void Association_text_with_a_broken_escape_reads_as_null_D4()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":"\ud800"},{"serialNumber":"PF3XKQ7","assignedToUserPrincipalName":"u\ud800"}]}""", Serial);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.AssignedToUserPrincipalName);
|
||||
}
|
||||
|
||||
// D2: the date keeps its fraction, in any culture (Newtonsoft cut it to whole seconds).
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("en-GB")]
|
||||
[InlineData("th-TH")]
|
||||
public void Association_date_keeps_its_fraction_in_any_culture_D2(string culture)
|
||||
{
|
||||
var date = InCulture(culture, () => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"2026-05-08T12:16:49.1234567Z"}]}""",
|
||||
Serial).PreAssociationDateTime);
|
||||
|
||||
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc).AddTicks(1234567), date);
|
||||
Assert.Equal(DateTimeKind.Utc, date!.Value.Kind);
|
||||
}
|
||||
|
||||
// ── CloudPcDeviceValidator.ParseCloudPcResponse ─────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("""{"value":[]} x""")]
|
||||
[InlineData("""{"value":null}""")]
|
||||
[InlineData("""{"value":[{"managedDeviceId":"other"}]}""")]
|
||||
public void CloudPc_list_without_the_device_is_not_found(string body)
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, IntuneId);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => CloudPcDeviceValidator.ParseCloudPcResponse(
|
||||
$$"""{"value":[null,{"managedDeviceId":"{{IntuneId}}"}]}""", IntuneId));
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_reads_the_matching_cloud_pc()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
|
||||
$$"""{"value":[{"id":"cpc-1","managedDeviceId":" {{IntuneId.ToUpperInvariant()}} ","managedDeviceName":"CPC-ADM-1","servicePlanName":"Cloud PC Enterprise"},null]}""",
|
||||
IntuneId);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("cpc-1", result.CloudPcId);
|
||||
Assert.Equal("CPC-ADM-1", result.ManagedDeviceName);
|
||||
Assert.Equal("Cloud PC Enterprise", result.ServicePlanName);
|
||||
}
|
||||
|
||||
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void CloudPc_fields_that_are_json_null_read_as_null_D1()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
|
||||
$$"""{"value":[{"managedDeviceId":"{{IntuneId}}","id":null,"managedDeviceName":null,"servicePlanName":null}]}""", IntuneId);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.CloudPcId);
|
||||
Assert.Null(result.ManagedDeviceName);
|
||||
Assert.Null(result.ServicePlanName);
|
||||
}
|
||||
|
||||
// ── IntuneDeviceBindingValidator.ParseManagedDeviceResponse ─────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("""[{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"}]""")]
|
||||
[InlineData("""{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"} x""")]
|
||||
[InlineData("""{"id":null}""")]
|
||||
[InlineData("""{"id":42}""")]
|
||||
[InlineData("""{"id":"other"}""")]
|
||||
public void Managed_device_that_is_not_the_requested_one_is_not_found(string body)
|
||||
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, IntuneId).Outcome);
|
||||
|
||||
[Theory]
|
||||
[InlineData("\"managed\"", IntuneDeviceBindingOutcome.Match)]
|
||||
[InlineData("null", IntuneDeviceBindingOutcome.Match)]
|
||||
[InlineData("\"retirePending\"", IntuneDeviceBindingOutcome.NotManaged)]
|
||||
[InlineData("\"WIPEISSUED\"", IntuneDeviceBindingOutcome.NotManaged)]
|
||||
[InlineData("\"deletePending\"", IntuneDeviceBindingOutcome.NotManaged)]
|
||||
public void Managed_device_on_its_way_out_is_not_managed(string managementState, IntuneDeviceBindingOutcome outcome)
|
||||
=> Assert.Equal(outcome, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":" {{IntuneId.ToUpperInvariant()}} ","managementState":{{managementState}}}""", IntuneId).Outcome);
|
||||
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("false", false)]
|
||||
[InlineData("null", null)]
|
||||
[InlineData("\"true\"", null)]
|
||||
[InlineData("1", null)]
|
||||
public void Managed_device_registration_flag_is_strictly_boolean(string value, bool? expected)
|
||||
=> Assert.Equal(expected, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","azureADRegistered":{{value}}}""", IntuneId).AzureAdRegistered);
|
||||
|
||||
[Theory]
|
||||
[InlineData("null")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\" \"")]
|
||||
public void Managed_device_telemetry_fields_that_are_blank_read_as_null(string value)
|
||||
{
|
||||
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","serialNumber":{{value}},"managedDeviceOwnerType":{{value}},"deviceEnrollmentType":{{value}},"enrolledDateTime":{{value}}}""",
|
||||
IntuneId);
|
||||
Assert.Null(result.SerialNumber);
|
||||
Assert.Null(result.OwnerType);
|
||||
Assert.Null(result.EnrollmentType);
|
||||
Assert.Null(result.EnrolledDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_reads_its_fields()
|
||||
{
|
||||
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","deviceName":"DESKTOP-1","azureADDeviceId":"aad-1","managementState":"managed","serialNumber":"PF3XKQ7","managedDeviceOwnerType":"company","deviceEnrollmentType":"windowsAzureADJoin","enrolledDateTime":"2026-05-08T12:16:49Z"}""",
|
||||
IntuneId);
|
||||
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
|
||||
Assert.Equal("DESKTOP-1", result.DeviceName);
|
||||
Assert.Equal("aad-1", result.AzureAdDeviceId);
|
||||
Assert.Equal("PF3XKQ7", result.SerialNumber);
|
||||
Assert.Equal("company", result.OwnerType);
|
||||
Assert.Equal("windowsAzureADJoin", result.EnrollmentType);
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero), result.EnrolledDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_unset_enrollment_date_reads_as_null()
|
||||
=> Assert.Null(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"0001-01-01T00:00:00Z"}""", IntuneId).EnrolledDateTime);
|
||||
|
||||
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void Managed_device_fields_that_are_json_null_read_as_null_D1()
|
||||
{
|
||||
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","deviceName":null,"azureADDeviceId":null,"managementState":null}""", IntuneId);
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
|
||||
Assert.Null(result.DeviceName);
|
||||
Assert.Null(result.AzureAdDeviceId);
|
||||
Assert.Null(result.ManagementState);
|
||||
}
|
||||
|
||||
// D2: the enrollment date keeps its fraction and its day and month in any culture (Newtonsoft cut the
|
||||
// fraction and swapped day and month on day-first cultures).
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("en-GB")]
|
||||
[InlineData("th-TH")]
|
||||
public void Managed_device_enrollment_date_is_the_same_in_any_culture_D2(string culture)
|
||||
{
|
||||
var date = InCulture(culture, () => IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"2026-05-08T12:16:49.1234567Z"}""", IntuneId).EnrolledDateTime);
|
||||
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), date);
|
||||
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
|
||||
}
|
||||
|
||||
// D4: an id that is not text never matches (Newtonsoft matched through its text).
|
||||
[Fact]
|
||||
public void Managed_device_with_a_numeric_id_is_not_found_D4()
|
||||
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound,
|
||||
IntuneDeviceBindingValidator.ParseManagedDeviceResponse("""{"id":12345}""", "12345").Outcome);
|
||||
|
||||
// ── Dates in any offset (new with System.Text.Json) ─────────────────────────
|
||||
|
||||
// Newtonsoft turned an offset date into machine-local time and read that back as UTC, so the instant
|
||||
// depended on the host's time zone; it was only right on a UTC host.
|
||||
[Theory]
|
||||
[InlineData("2026-05-08T14:16:49+02:00")]
|
||||
[InlineData("2026-05-08T07:16:49-05:00")]
|
||||
[InlineData("2026-05-08T12:16:49Z")]
|
||||
[InlineData("2026-05-08T12:16:49")]
|
||||
public void Dates_in_any_offset_read_as_the_same_utc_instant(string value)
|
||||
{
|
||||
var expected = new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc);
|
||||
|
||||
var association = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
$$"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"{{value}}"}]}""", Serial).PreAssociationDateTime;
|
||||
var enrolled = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"{{value}}"}""", IntuneId).EnrolledDateTime;
|
||||
|
||||
Assert.Equal(expected, association);
|
||||
Assert.Equal(DateTimeKind.Utc, association!.Value.Kind);
|
||||
Assert.Equal(new DateTimeOffset(expected), enrolled);
|
||||
Assert.Equal(TimeSpan.Zero, enrolled!.Value.Offset);
|
||||
}
|
||||
|
||||
// ── ScriptDisplayNameResolver.ReadScriptPage / ReadScriptDisplayName ────────
|
||||
|
||||
[Fact]
|
||||
public void Script_page_keeps_object_entries_with_an_id_and_the_next_url()
|
||||
{
|
||||
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(
|
||||
"""{"value":[1,"x",null,[],{"displayName":"no id"},{"id":null},{"id":""},{"id":"s1","displayName":"A","fileName":"a.ps1"},{"id":"s2","displayName":"B"}],"@odata.nextLink":"https://graph.microsoft.com/beta/p2"}""",
|
||||
withFileName: true);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
new ScriptDisplayNameResolver.ScriptListItem("s1", "A", "a.ps1"),
|
||||
new ScriptDisplayNameResolver.ScriptListItem("s2", "B", null),
|
||||
}, scripts);
|
||||
Assert.Equal("https://graph.microsoft.com/beta/p2", next);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"value":[{"id":"s1"}]}""")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":null}""")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":""}""")]
|
||||
[InlineData("""{"value":"oops"}""")]
|
||||
[InlineData("{}")]
|
||||
public void Script_page_without_a_next_link_ends_the_pull(string body)
|
||||
=> Assert.True(string.IsNullOrEmpty(ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false).NextUrl));
|
||||
|
||||
[Fact]
|
||||
public void Script_page_of_health_scripts_reads_no_file_name()
|
||||
=> Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
|
||||
"""{"value":[{"id":"s1","displayName":"A","fileName":"a.ps1"}]}""", withFileName: false).Scripts).FileName);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("not json")]
|
||||
public void Script_page_or_name_that_is_not_one_object_throws(string body)
|
||||
{
|
||||
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false));
|
||||
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptDisplayName(body));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"displayName":"Install agent"}""", "Install agent")]
|
||||
[InlineData("""{"id":"s1"}""", null)]
|
||||
public void Script_name_reads_the_display_name(string body, string? name)
|
||||
=> Assert.Equal(name, ScriptDisplayNameResolver.ReadScriptDisplayName(body));
|
||||
|
||||
// D1: a display name that is JSON null is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void Script_name_that_is_json_null_reads_as_null_D1()
|
||||
{
|
||||
Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":null}"""));
|
||||
Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
|
||||
"""{"value":[{"id":"s1","displayName":null}]}""", withFileName: false).Scripts).DisplayName);
|
||||
}
|
||||
|
||||
// D3: a display name is kept as sent in any culture, even when it looks like a date (Newtonsoft
|
||||
// rewrote it in the current culture, "1/1/2026 12:00:00 AM" in en-US).
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("th-TH")]
|
||||
public void Script_name_shaped_like_a_date_is_kept_as_sent_D3(string culture)
|
||||
=> Assert.Equal("2026-01-01T00:00:00Z", InCulture(culture, () =>
|
||||
ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":"2026-01-01T00:00:00Z"}""")));
|
||||
|
||||
// D4: a display name that is not text is absent (Newtonsoft read a number as its text).
|
||||
[Fact]
|
||||
public void Script_name_that_is_a_number_reads_as_null_D4()
|
||||
=> Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":42}"""));
|
||||
|
||||
// ── D7: a body cut off mid-object is unreadable ─────────────────────────────
|
||||
|
||||
// Newtonsoft's DeserializeObject read the part before the cut as if the open containers were closed, so a
|
||||
// cut-off list could still match. Every parser now answers it like any other unreadable body. HttpClient
|
||||
// throws on a premature end before a parser sees the body, so only a server-side cut can get here.
|
||||
|
||||
[Fact]
|
||||
public void Token_response_cut_off_throws_D7()
|
||||
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse("""{"access_token":"t","expires_in":3599"""));
|
||||
|
||||
[Fact]
|
||||
public void Entra_rejection_cut_off_keeps_only_the_status_D7()
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(400, """{"error_codes":[53003""");
|
||||
Assert.Null(rejection.ErrorCode);
|
||||
Assert.Null(rejection.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_cut_off_throws_D7()
|
||||
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7"}""", Serial));
|
||||
|
||||
[Fact]
|
||||
public void Corporate_page_cut_off_throws_D7()
|
||||
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(
|
||||
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}""",
|
||||
CorporateId));
|
||||
|
||||
[Fact]
|
||||
public void Association_list_cut_off_is_not_found_D7()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse("""{"value":[{"serialNumber":"PF3XKQ7"}""", Serial);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_cut_off_is_not_found_D7()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse($$"""{"value":[{"managedDeviceId":"{{IntuneId}}"}""", IntuneId);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_cut_off_is_not_found_D7()
|
||||
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","azureADRegistered":true""", IntuneId).Outcome);
|
||||
|
||||
private static T InCulture<T>(string culture, Func<T> read)
|
||||
{
|
||||
var saved = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
||||
return read();
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public class GraphTokenServiceTests
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler)
|
||||
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler, IMemoryCache? cache = null)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
@@ -33,7 +33,7 @@ public class GraphTokenServiceTests
|
||||
})
|
||||
.Build();
|
||||
|
||||
var svc = NewService(config, new StubHttpClientFactory(handler));
|
||||
var svc = NewService(config, new StubHttpClientFactory(handler), cache: cache);
|
||||
|
||||
return (svc, handler);
|
||||
}
|
||||
@@ -47,11 +47,12 @@ public class GraphTokenServiceTests
|
||||
IConfiguration config,
|
||||
System.Net.Http.IHttpClientFactory factory,
|
||||
IGraphAccessHealthRecorder? accessHealth = null,
|
||||
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null) =>
|
||||
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null,
|
||||
IMemoryCache? cache = null) =>
|
||||
new GraphTokenService(
|
||||
logger ?? NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
cache ?? new MemoryCache(new MemoryCacheOptions()),
|
||||
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
@@ -131,6 +132,53 @@ public class GraphTokenServiceTests
|
||||
Assert.Equal(1, TokenPosts(handler, TenantA));
|
||||
}
|
||||
|
||||
// The token stays cached for expires_in minus the 5-minute skew, clamped to 60 s .. 55 min. An absent
|
||||
// or unusable expires_in (D5 in D-420: text, a fraction, zero or less) falls back to the 35-minute
|
||||
// default, so 30 minutes; a value far beyond the cap is capped instead of overflowing.
|
||||
[Theory]
|
||||
[InlineData("3599", 3299)]
|
||||
[InlineData("\"3599\"", 3299)]
|
||||
[InlineData("7200", 3300)]
|
||||
[InlineData("120", 60)]
|
||||
[InlineData("null", 1800)]
|
||||
[InlineData("\"abc\"", 1800)]
|
||||
[InlineData("3599.5", 1800)]
|
||||
[InlineData("0", 1800)]
|
||||
[InlineData("-5", 1800)]
|
||||
[InlineData("9223372036854775807", 3300)]
|
||||
public async Task Token_cache_lifetime_follows_expires_in(string expiresIn, int seconds)
|
||||
{
|
||||
var cache = new RecordingMemoryCache();
|
||||
var (svc, _) = Create(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, $$"""{"access_token":"tok-A","expires_in":{{expiresIn}}}"""), cache);
|
||||
|
||||
Assert.Equal("tok-A", (await svc.GetAccessTokenAsync(TenantA)).AccessToken);
|
||||
|
||||
var entry = Assert.Single(cache.Entries, e => e.Key is string key && key.StartsWith($"graph-token:{TenantA}:", StringComparison.Ordinal));
|
||||
Assert.Equal(TimeSpan.FromSeconds(seconds), entry.AbsoluteExpirationRelativeToNow);
|
||||
}
|
||||
|
||||
/// <summary>A memory cache that keeps every entry it creates, so a test can read the lifetime the service chose.</summary>
|
||||
private sealed class RecordingMemoryCache : IMemoryCache
|
||||
{
|
||||
private readonly MemoryCache _inner = new(new MemoryCacheOptions());
|
||||
|
||||
public List<ICacheEntry> Entries { get; } = new();
|
||||
|
||||
public ICacheEntry CreateEntry(object key)
|
||||
{
|
||||
var entry = _inner.CreateEntry(key);
|
||||
Entries.Add(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public bool TryGetValue(object key, out object? value) => _inner.TryGetValue(key, out value);
|
||||
|
||||
public void Remove(object key) => _inner.Remove(key);
|
||||
|
||||
public void Dispose() => _inner.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateTenant_forces_fresh_token_on_next_call()
|
||||
{
|
||||
@@ -451,7 +499,7 @@ public class GraphTokenServiceTests
|
||||
logger.Verify(l => l.Log(
|
||||
Microsoft.Extensions.Logging.LogLevel.Warning,
|
||||
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
|
||||
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
|
||||
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state != null && state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
|
||||
Moq.It.IsAny<Exception?>(),
|
||||
Moq.It.IsAny<Func<Moq.It.IsAnyType, Exception?, string>>()), Moq.Times.Once);
|
||||
}
|
||||
|
||||
@@ -124,32 +124,38 @@ public class IntuneDeviceBindingValidatorTests
|
||||
Assert.Null(result.EnrolledDateTime);
|
||||
}
|
||||
|
||||
// -- ParseEnrolledDateTime --
|
||||
// -- enrolledDateTime --
|
||||
|
||||
private static DateTimeOffset? EnrolledDateTime(string jsonValue)
|
||||
=> IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{ "id": "{{DeviceId}}", "enrolledDateTime": {{jsonValue}} }""", DeviceId).EnrolledDateTime;
|
||||
|
||||
[Fact]
|
||||
public void ParseEnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
|
||||
public void EnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
|
||||
{
|
||||
// Intune returns 0001-01-01T00:00:00Z when it has no enrollment timestamp. Treating that
|
||||
// as a real date would report an object age of ~2000 years and poison the race analysis.
|
||||
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime("0001-01-01T00:00:00Z"));
|
||||
Assert.Null(EnrolledDateTime("\"0001-01-01T00:00:00Z\""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not-a-date")]
|
||||
public void ParseEnrolledDateTime_UnusableInput_IsNull(string? raw)
|
||||
[InlineData("null")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\" \"")]
|
||||
[InlineData("\"not-a-date\"")]
|
||||
[InlineData("20260824")]
|
||||
[InlineData("true")]
|
||||
public void EnrolledDateTime_UnusableValue_IsNull(string jsonValue)
|
||||
{
|
||||
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime(raw));
|
||||
Assert.Null(EnrolledDateTime(jsonValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnrolledDateTime_NormalizesToUtc()
|
||||
public void EnrolledDateTime_NormalizesToUtc()
|
||||
{
|
||||
// A device object created minutes ago is the signature of an enrollment race, so the
|
||||
// instant has to be comparable regardless of the offset Graph reports it in.
|
||||
var parsed = IntuneDeviceBindingValidator.ParseEnrolledDateTime("2026-08-24T17:20:00+02:00");
|
||||
var parsed = EnrolledDateTime("\"2026-08-24T17:20:00+02:00\"");
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(TimeSpan.Zero, parsed!.Value.Offset);
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Graph and Entra token-endpoint response parsers as they read with Newtonsoft before D-420, copied
|
||||
/// verbatim as the reference for <see cref="UpstreamJsonDifferentialTests"/>. Temporary: removed together
|
||||
/// with that test once the System.Text.Json parsers have run cleanly in production (backlog).
|
||||
/// </summary>
|
||||
internal static class LegacyUpstreamParsers
|
||||
{
|
||||
public static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody)
|
||||
{
|
||||
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
var accessToken = tokenJson?["access_token"]?.ToString();
|
||||
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, (long?)tokenJson?["expires_in"]);
|
||||
}
|
||||
|
||||
public static EntraTokenRejection ParseEntraRejection(int statusCode, string? body)
|
||||
{
|
||||
JObject? json = null;
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try { json = JsonConvert.DeserializeObject<JToken>(body) as JObject; }
|
||||
catch (JsonException) { json = null; }
|
||||
}
|
||||
var description = (json?["error_description"] as JValue)?.Value as string;
|
||||
|
||||
string? code = null;
|
||||
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
|
||||
if (first != null)
|
||||
{
|
||||
code = "AADSTS" + first.Value<long>();
|
||||
}
|
||||
else
|
||||
{
|
||||
var start = description?.IndexOf("AADSTS", StringComparison.Ordinal) ?? -1;
|
||||
if (start >= 0)
|
||||
{
|
||||
var digits = new string(description!.Skip(start + "AADSTS".Length).TakeWhile(char.IsDigit).ToArray());
|
||||
code = digits.Length == 0 ? null : "AADSTS" + digits;
|
||||
}
|
||||
}
|
||||
|
||||
string? shortened = null;
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
var line = description.Split('\n')[0].Trim();
|
||||
shortened = line.Length <= 300 ? line : line.Substring(0, 300);
|
||||
}
|
||||
return new EntraTokenRejection(statusCode, code, shortened);
|
||||
}
|
||||
|
||||
public static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial)
|
||||
{
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
if (data?["value"] is not JArray devices || devices.Count == 0)
|
||||
return null;
|
||||
|
||||
var exactDevice = devices.FirstOrDefault(d => string.Equals(
|
||||
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
|
||||
if (exactDevice == null)
|
||||
return null;
|
||||
|
||||
return new AutopilotRegistration
|
||||
{
|
||||
DeviceId = RegistrationText(exactDevice["id"]),
|
||||
GroupTag = RegistrationText(exactDevice["groupTag"]),
|
||||
PurchaseOrderId = RegistrationText(exactDevice["purchaseOrderIdentifier"]),
|
||||
DisplayName = RegistrationText(exactDevice["displayName"]),
|
||||
UserAssigned = RegistrationText(exactDevice["userPrincipalName"]) != null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? RegistrationText(JToken? token)
|
||||
{
|
||||
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return null;
|
||||
return value!.Length > AutopilotDeviceValidator.MaxRegistrationFieldLength
|
||||
? value.Substring(0, AutopilotDeviceValidator.MaxRegistrationFieldLength)
|
||||
: value;
|
||||
}
|
||||
|
||||
public static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier)
|
||||
{
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
if (data?["value"] is JArray identities)
|
||||
{
|
||||
foreach (var identity in identities)
|
||||
{
|
||||
var type = identity?["importedDeviceIdentityType"]?.ToString();
|
||||
var value = identity?["importedDeviceIdentifier"]?.ToString();
|
||||
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
|
||||
&& value != null
|
||||
&& string.Equals(CorporateIdentifierValidator.NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
|
||||
{
|
||||
return (true, string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (false, data?["@odata.nextLink"]?.ToString() ?? string.Empty);
|
||||
}
|
||||
|
||||
public static DeviceAssociationResult ParseAssociation(string responseBody, string normalizedSerial)
|
||||
{
|
||||
var notFound = new DeviceAssociationResult
|
||||
{
|
||||
IsValid = false,
|
||||
SerialNumber = normalizedSerial,
|
||||
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
|
||||
};
|
||||
|
||||
JObject? data;
|
||||
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
|
||||
catch (JsonException) { return notFound; }
|
||||
|
||||
var devices = data?["value"] as JArray;
|
||||
if (devices == null || devices.Count == 0)
|
||||
return notFound;
|
||||
|
||||
var match = devices.FirstOrDefault(d => string.Equals(
|
||||
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
|
||||
if (match == null)
|
||||
return notFound;
|
||||
|
||||
return new DeviceAssociationResult
|
||||
{
|
||||
IsValid = true,
|
||||
SerialNumber = normalizedSerial,
|
||||
AssociationState = match["associationState"]?.ToString(),
|
||||
DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(),
|
||||
PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(),
|
||||
AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(),
|
||||
PreAssociationDateTime = ParseAssociationDate(match["preassociationDateTime"]),
|
||||
AssociationDateTime = ParseAssociationDate(match["associationDateTime"]),
|
||||
ManagedDeviceId = match["managedDeviceId"]?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime? ParseAssociationDate(JToken? token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null) return null;
|
||||
var s = token.ToString();
|
||||
if (string.IsNullOrEmpty(s)) return null;
|
||||
if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
|
||||
return dt.Year <= 1 ? null : dt;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static CloudPcValidationResult ParseCloudPc(string responseBody, string normalizedId)
|
||||
{
|
||||
var notFound = new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
|
||||
};
|
||||
|
||||
JObject? data;
|
||||
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
|
||||
catch (JsonException) { return notFound; }
|
||||
|
||||
var cloudPcs = data?["value"] as JArray;
|
||||
if (cloudPcs == null || cloudPcs.Count == 0)
|
||||
return notFound;
|
||||
|
||||
var match = cloudPcs.FirstOrDefault(c => string.Equals(
|
||||
c?["managedDeviceId"]?.ToString()?.Trim(), normalizedId, StringComparison.OrdinalIgnoreCase));
|
||||
if (match == null)
|
||||
return notFound;
|
||||
|
||||
return new CloudPcValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
IntuneDeviceId = normalizedId,
|
||||
CloudPcId = match["id"]?.ToString(),
|
||||
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
|
||||
ServicePlanName = match["servicePlanName"]?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> LeavingManagementStates = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"retirePending", "retireIssued", "wipePending", "wipeIssued", "deletePending",
|
||||
};
|
||||
|
||||
public static IntuneDeviceBindingResult ParseManagedDevice(string responseBody, string normalizedId)
|
||||
{
|
||||
var notFound = new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = IntuneDeviceBindingOutcome.NotFound,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
|
||||
};
|
||||
|
||||
JObject? device;
|
||||
try { device = JsonConvert.DeserializeObject<JObject>(responseBody); }
|
||||
catch (JsonException) { return notFound; }
|
||||
|
||||
var returnedId = device?["id"]?.ToString()?.Trim();
|
||||
if (string.IsNullOrEmpty(returnedId) || !string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
|
||||
return notFound;
|
||||
|
||||
var managementState = device?["managementState"]?.ToString();
|
||||
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
|
||||
|
||||
return new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
|
||||
IntuneDeviceId = normalizedId,
|
||||
DeviceName = device?["deviceName"]?.ToString(),
|
||||
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
|
||||
ManagementState = managementState,
|
||||
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
|
||||
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
|
||||
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
|
||||
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
|
||||
? device["azureADRegistered"]!.Value<bool>()
|
||||
: null,
|
||||
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
|
||||
ErrorMessage = leaving
|
||||
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseEnrolledDateTime(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
if (!DateTimeOffset.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var parsed))
|
||||
return null;
|
||||
return parsed.Year <= 1 ? null : parsed;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
public static (List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) ReadScriptPage(string body, bool withFileName)
|
||||
{
|
||||
var json = JObject.Parse(body);
|
||||
var values = json["value"] as JArray ?? new JArray();
|
||||
var scripts = new List<ScriptDisplayNameResolver.ScriptListItem>();
|
||||
foreach (var item in values.OfType<JObject>())
|
||||
{
|
||||
var id = item["id"]?.ToString();
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
scripts.Add(new ScriptDisplayNameResolver.ScriptListItem(id!, item["displayName"]?.ToString(), withFileName ? item["fileName"]?.ToString() : null));
|
||||
}
|
||||
return (scripts, json["@odata.nextLink"]?.ToString());
|
||||
}
|
||||
|
||||
public static string? ReadScriptDisplayName(string body) => JObject.Parse(body)["displayName"]?.ToString();
|
||||
}
|
||||
@@ -45,6 +45,24 @@ public class LiveStreamPrincipalTests
|
||||
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.NegotiatedUserIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negotiate_under_view_as_binds_the_sign_in_tenant_not_the_viewed_one()
|
||||
{
|
||||
// Removing a Global Admin cuts the connections bound to their sign-in tenant; a view-as tab must be among them.
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var req = BuildRequest(new RequestContext
|
||||
{
|
||||
TenantId = OtherTenantId,
|
||||
UserPrincipalName = OperatorUpn,
|
||||
ViewAs = new ViewAsContext(TenantId, AutopilotMonitor.Shared.Constants.TenantRoles.Admin),
|
||||
});
|
||||
|
||||
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, OperatorUpn) }, signalR.NegotiatedUserIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negotiate_without_a_principal_key_binds_no_user_id()
|
||||
{
|
||||
|
||||
+3
-3
@@ -96,7 +96,7 @@ public class McpOrganizationUsageAggregationTests
|
||||
Row(Member, "20260903", 7), // read window past today (range only)
|
||||
};
|
||||
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
var quota = McpQuotaNodes.BuildOrganizationQuota(
|
||||
rows, new McpQuotaService.TenantPlanLimits("pro", 1500, 15000), "20260902", "20260901");
|
||||
|
||||
Assert.Equal("pro", quota.TenantPlan);
|
||||
@@ -114,7 +114,7 @@ public class McpOrganizationUsageAggregationTests
|
||||
public void OrganizationQuota_PurchasedSlots_CarryTheBreakdown()
|
||||
{
|
||||
// 3 000 + 3 × 900 = 5 700 — the limits arrive grown; the slot fields let the page show the sum.
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
var quota = McpQuotaNodes.BuildOrganizationQuota(
|
||||
new[] { Row(Member, "20260902", 3) },
|
||||
new McpQuotaService.TenantPlanLimits("pro", 5700, 114000, PurchasedDelegatedSlots: 3, SlotTenantDailyLimit: 900, SlotTenantMonthlyLimit: 18000),
|
||||
"20260902", "20260901");
|
||||
@@ -145,7 +145,7 @@ public class McpOrganizationUsageAggregationTests
|
||||
[Fact]
|
||||
public void OrganizationQuota_LiftedWindows_StillReportTheCounters()
|
||||
{
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
var quota = McpQuotaNodes.BuildOrganizationQuota(
|
||||
new[] { Row(Member, "20260902", 3) }, new McpQuotaService.TenantPlanLimits("pro", 0, 0), "20260902", "20260901");
|
||||
|
||||
Assert.Equal(0, quota.DailyLimit);
|
||||
|
||||
@@ -96,6 +96,47 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Error);
|
||||
}
|
||||
|
||||
// ── ExceededAlertKey — one McpQuotaExceeded per account or organization, window and period ──
|
||||
|
||||
private const string OidA = "00000000-0000-0000-0000-00000000000a";
|
||||
private const string OidB = "00000000-0000-0000-0000-00000000000b";
|
||||
|
||||
private static McpQuotaDecision UserDailyBlock(DateTime now)
|
||||
=> McpQuotaService.BuildDecision(new McpPlanLimits("community", 50, 250, "community", 100, 250),
|
||||
dailyUsed: 50, monthlyUsed: 60, tenantDailyUsed: 60, tenantMonthlyUsed: 60, now);
|
||||
|
||||
[Fact]
|
||||
public void AlertKey_AccountWindow_IsPerAccountAndDay()
|
||||
{
|
||||
var key = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, UserDailyBlock(Now));
|
||||
|
||||
Assert.Equal($"user:daily:{OidA}:20260903", key); // the period is the reset date
|
||||
Assert.NotEqual(key, McpQuotaEnforcementMiddleware.ExceededAlertKey(OidB, Home, UserDailyBlock(Now)));
|
||||
Assert.NotEqual(key, McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, UserDailyBlock(Now.AddDays(1))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlertKey_OrganizationWindow_IsSharedByAllMembers()
|
||||
{
|
||||
// The organization's window is one budget: the refusal of any member raises one event for it.
|
||||
var tenantBlock = Blocked("community");
|
||||
|
||||
var a = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home.ToUpperInvariant(), tenantBlock);
|
||||
var b = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidB, Home, tenantBlock);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal($"tenant:daily:{Home}:20260903", a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlertKey_MonthlyWindow_IsItsOwnKey()
|
||||
{
|
||||
var monthly = McpQuotaService.BuildDecision(new McpPlanLimits("community", 50, 250, "community", 100, 250),
|
||||
dailyUsed: 10, monthlyUsed: 250, tenantDailyUsed: 10, tenantMonthlyUsed: 10, Now);
|
||||
|
||||
Assert.Equal($"user:monthly:{OidA}:20261001", McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, monthly));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserLevelBlock_ReportsTheUserWindow()
|
||||
{
|
||||
|
||||
@@ -130,7 +130,8 @@ public class McpQuotaServiceTests
|
||||
Func<string?, TenantEdition>? editionResolver = null,
|
||||
Func<string?, string?>? tenantPlanOverrideResolver = null,
|
||||
Func<string?, int>? purchasedSlotsResolver = null,
|
||||
Func<string?, EditionResolution>? resolutionResolver = null)
|
||||
Func<string?, EditionResolution>? resolutionResolver = null,
|
||||
TestTimeProvider? time = null)
|
||||
{
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny<string>()))
|
||||
@@ -162,9 +163,8 @@ public class McpQuotaServiceTests
|
||||
mcpUserService,
|
||||
adminConfigService,
|
||||
entitlements,
|
||||
cache,
|
||||
NullLogger<McpQuotaService>.Instance,
|
||||
new TestTimeProvider(Now));
|
||||
time ?? new TestTimeProvider(Now));
|
||||
}
|
||||
|
||||
private static Mock<IUserUsageRepository> UsageRepo(params (string Date, long Count)[] rows)
|
||||
@@ -197,7 +197,7 @@ public class McpQuotaServiceTests
|
||||
// 100 requests today (catalog Community fallback: 100/day) → blocked.
|
||||
var svc = Build(UsageRepo(("20260707", 100)));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("community", d.Plan);
|
||||
@@ -210,7 +210,7 @@ public class McpQuotaServiceTests
|
||||
{
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("pro", d.Plan);
|
||||
@@ -224,7 +224,7 @@ public class McpQuotaServiceTests
|
||||
// 2990 across the month + 20 today = 3010 ≥ 3000 monthly Community limit.
|
||||
var svc = Build(UsageRepo(("20260701", 1500), ("20260703", 1490), ("20260707", 20)));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
@@ -238,7 +238,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"community","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 5)), planDefinitionsJson: json);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal(5, d.DailyLimit);
|
||||
@@ -251,7 +251,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
@@ -266,7 +266,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power", bound: false);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("community", d.Plan);
|
||||
@@ -279,7 +279,7 @@ public class McpQuotaServiceTests
|
||||
// Override names a plan that exists nowhere → Community fallback limits.
|
||||
var svc = Build(UsageRepo(("20260707", 100)), mcpUserPlanOverride: "no-such-plan");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("no-such-plan", d.Plan);
|
||||
@@ -300,7 +300,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 1200)), planDefinitionsJson: MspPlanJson,
|
||||
edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("msp", d.Plan);
|
||||
@@ -318,7 +318,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 2000)), planDefinitionsJson: json,
|
||||
edition: TenantEdition.Pro, mcpUserPlanOverride: "power", tenantPlanOverrideResolver: _ => "msp");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
@@ -335,7 +335,7 @@ public class McpQuotaServiceTests
|
||||
// falls back fail-closed (user: Community catalog; tenant: the EDITION's catalog windows).
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("msp", d.Plan);
|
||||
@@ -351,7 +351,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: MspPlanJson,
|
||||
edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp", purchasedSlotsResolver: _ => 2);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal("msp", d.Plan);
|
||||
Assert.Equal(2100, d.DailyLimit); // 1500 + 2 × 300
|
||||
@@ -367,8 +367,8 @@ public class McpQuotaServiceTests
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var svc = Build(repo);
|
||||
|
||||
var first = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var first = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(first.Allowed);
|
||||
Assert.True(second.Allowed);
|
||||
@@ -383,69 +383,144 @@ public class McpQuotaServiceTests
|
||||
var repo = UsageRepo(("20260707", 1));
|
||||
var svc = Build(repo);
|
||||
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
// ── Soft-boundary contract (Codex finding 2026-07-07) ─────────────────────────
|
||||
// The quota is DELIBERATELY soft: the per-user decision is cached for 60s, so an allowed
|
||||
// decision keeps admitting requests inside that window even after the fire-and-forget
|
||||
// increments push the stored counters past the limit. Overshoot is bounded (~TTL × request
|
||||
// rate per instance) — the same posture as the sliding-window rate limiter. These tests PIN
|
||||
// that contract; if strict limit+1 blocking is ever wanted, the decision cache must be
|
||||
// reworked and these tests consciously rewritten.
|
||||
// ── Boundary: exact per instance, soft across instances ──────────────────────
|
||||
// The counters are read from storage at most every 60 s, and every request the instance admits is
|
||||
// counted on them at once — so one instance blocks exactly at the limit, also when a tool call fans out
|
||||
// into parallel requests. What stays soft is the part of OTHER instances: they learn of these requests
|
||||
// only with their own next read.
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SoftBoundary_CachedAllowedDecision_KeepsAdmitting_WithinTtl()
|
||||
public async Task Check_SequentialRequests_BlockExactlyAtTheLimit_WithoutRereading()
|
||||
{
|
||||
// First read: one request below the daily limit → allowed, decision cached.
|
||||
var repo = UsageRepo(("20260707", 99));
|
||||
// Community catalog: 100/day. 98 served before → the 99th and 100th pass, the 101st is refused.
|
||||
var repo = UsageRepo(("20260707", 98));
|
||||
var svc = Build(repo);
|
||||
|
||||
var first = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
Assert.True(first.Allowed);
|
||||
var ninetyNinth = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var hundredth = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var hundredFirst = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
// The async increments land: stored counters are now OVER the limit — but the cached
|
||||
// allowed decision keeps admitting within the TTL, without re-reading storage.
|
||||
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<UserUsageRecord>
|
||||
{
|
||||
new() { UserId = Oid, Date = "20260707", RequestCount = 150 }
|
||||
});
|
||||
|
||||
var second = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(second.Allowed, "soft boundary: cached allowed decision persists within the TTL");
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
Assert.True(ninetyNinth.Allowed);
|
||||
Assert.Equal(98, ninetyNinth.DailyUsed); // the decision reports the count BEFORE this request
|
||||
Assert.True(hundredth.Allowed);
|
||||
Assert.False(hundredFirst.Allowed);
|
||||
Assert.Equal("daily", hundredFirst.Scope);
|
||||
Assert.Equal(100, hundredFirst.DailyUsed);
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SoftBoundary_AfterCacheEviction_OverLimitCounters_Block()
|
||||
public async Task ReadState_NeverCounts_OnlyAdmitDoes()
|
||||
{
|
||||
// Same scenario as above, but once the cached decision is gone (TTL expiry — simulated
|
||||
// via eviction), the next check reads the over-limit counters and blocks.
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
// The usage page shows the quota; viewing it is not an MCP request. Three reads leave the count at 99,
|
||||
// so the next real request still passes and only the one after it is refused.
|
||||
var svc = Build(UsageRepo(("20260707", 99)));
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var view = await svc.ReadStateAsync(Oid, Upn, TenantId);
|
||||
Assert.True(view.Allowed);
|
||||
Assert.Equal(99, view.DailyUsed);
|
||||
}
|
||||
|
||||
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.False((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.Equal(100, (await svc.ReadStateAsync(Oid, Upn, TenantId)).DailyUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_ParallelBurst_AdmitsExactlyUpToTheLimit()
|
||||
{
|
||||
// A tool call's parallel requests must not all pass on the same reading: 95 used, 20 at once → 5 pass.
|
||||
var repo = UsageRepo(("20260707", 95));
|
||||
var svc = Build(repo);
|
||||
|
||||
var decisions = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() => svc.AdmitAsync(Oid, Upn, TenantId))));
|
||||
|
||||
Assert.Equal(5, decisions.Count(d => d.Allowed));
|
||||
// Single-flight: concurrent first checks share one counter, so storage was read once.
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TenantWindow_CountsEveryMemberOnThisInstance()
|
||||
{
|
||||
// Community organization window 300/day, 298 used by the organization. Member A and member B each get one
|
||||
// request through; the next one of A is refused at the organization level although A's own window is wide.
|
||||
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 298L) });
|
||||
repo.Setup(r => r.GetUsageByUserAsync(OtherOid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<UserUsageRecord> { new() { UserId = OtherOid, Date = "20260707", RequestCount = 1 } });
|
||||
var svc = Build(repo);
|
||||
|
||||
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await svc.AdmitAsync(OtherOid, "bob@contoso.com", TenantId)).Allowed);
|
||||
var third = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(third.Allowed);
|
||||
Assert.Equal("tenant", third.Level);
|
||||
Assert.Equal(300, third.TenantDailyUsed);
|
||||
repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SecondInstance_LearnsOfTheFirstOnlyWithItsOwnRead()
|
||||
{
|
||||
// The part that stays soft, pinned: two instances share storage, not memory. Storage still says 99 (the
|
||||
// increments of instance A are on their way), so instance B admits one more on its own fresh reading.
|
||||
var repo = UsageRepo(("20260707", 99));
|
||||
var svc = Build(repo, cache: cache);
|
||||
var instanceA = Build(repo);
|
||||
var instanceB = Build(repo);
|
||||
|
||||
Assert.True((await svc.CheckAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await instanceA.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.False((await instanceA.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await instanceB.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.False((await instanceB.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_CounterExpiry_RereadsStorage()
|
||||
{
|
||||
// After 60 s the counter is read again, so increments of other instances count from then on.
|
||||
var time = new TestTimeProvider(Now);
|
||||
var repo = UsageRepo(("20260707", 10));
|
||||
var svc = Build(repo, time: time);
|
||||
|
||||
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<UserUsageRecord>
|
||||
{
|
||||
new() { UserId = Oid, Date = "20260707", RequestCount = 150 }
|
||||
});
|
||||
cache.Remove(McpQuotaService.UserCacheKey(Oid)); // stands in for the 60s TTL elapsing
|
||||
.ReturnsAsync(new List<UserUsageRecord> { new() { UserId = Oid, Date = "20260707", RequestCount = 150 } });
|
||||
time.SetUtcNow(Now.AddSeconds(61));
|
||||
|
||||
var decision = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var decision = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(decision.Allowed);
|
||||
Assert.Equal("daily", decision.Scope);
|
||||
Assert.Equal(150, decision.DailyUsed);
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_Midnight_StartsAFreshCounter_InsideTheSixtySeconds()
|
||||
{
|
||||
// A counter read at 23:59:50 never answers a check at 00:00:05: the day is part of the key.
|
||||
var lateEvening = new DateTime(2026, 7, 7, 23, 59, 50, DateTimeKind.Utc);
|
||||
var time = new TestTimeProvider(lateEvening);
|
||||
var repo = UsageRepo(("20260707", 100));
|
||||
var svc = Build(repo, time: time);
|
||||
|
||||
Assert.False((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
|
||||
time.SetUtcNow(lateEvening.AddSeconds(15));
|
||||
var afterMidnight = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(afterMidnight.Allowed);
|
||||
Assert.Equal(0, afterMidnight.DailyUsed);
|
||||
Assert.Equal(new DateTime(2026, 7, 9, 0, 0, 0, DateTimeKind.Utc), afterMidnight.ResetUtc);
|
||||
}
|
||||
|
||||
// ── Tenant (organization-wide) quota ─────────────────────────────────────────
|
||||
@@ -456,7 +531,7 @@ public class McpQuotaServiceTests
|
||||
// Community catalog tenant window = 300/day; other members burned it, this caller made 5 requests.
|
||||
var svc = Build(UsageRepo(new[] { ("20260707", 5L) }, tenantRows: new[] { ("20260707", 300L) }));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
@@ -474,7 +549,7 @@ public class McpQuotaServiceTests
|
||||
new[] { ("20260707", 1L) },
|
||||
tenantRows: new[] { ("20260701", 4000L), ("20260703", 4990L), ("20260707", 10L) }));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
@@ -492,7 +567,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 10L) }),
|
||||
planDefinitionsJson: json, mcpUserPlanOverride: "power");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
@@ -509,7 +584,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(5, d.DailyLimit);
|
||||
@@ -524,7 +599,7 @@ public class McpQuotaServiceTests
|
||||
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 999999L) });
|
||||
var svc = Build(repo, planDefinitionsJson: json);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(0, d.TenantDailyLimit);
|
||||
@@ -537,7 +612,7 @@ public class McpQuotaServiceTests
|
||||
var repo = UsageRepo(("20260707", 1));
|
||||
var svc = Build(repo);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, tenantId: null);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, tenantId: null);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
repo.Verify(r => r.GetTenantUsageAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
@@ -551,8 +626,8 @@ public class McpQuotaServiceTests
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var svc = Build(repo);
|
||||
|
||||
var first = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var first = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(first.Allowed);
|
||||
Assert.True(second.Allowed);
|
||||
@@ -573,7 +648,7 @@ public class McpQuotaServiceTests
|
||||
// Pro home with 3 purchased slots: 1000 + 3 × 300 per account, 3000 + 3 × 900 for the organization.
|
||||
var svc = Build(UsageRepo(("20260707", 1)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 3);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1900, d.DailyLimit);
|
||||
Assert.Equal(38000, d.MonthlyLimit);
|
||||
@@ -592,7 +667,7 @@ public class McpQuotaServiceTests
|
||||
// A Pro tenant on its two included slots (purchased = 0) keeps the plain Pro windows.
|
||||
var svc = Build(UsageRepo(("20260707", 1)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 0);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
Assert.Equal(3000, d.TenantDailyLimit);
|
||||
@@ -607,7 +682,7 @@ public class McpQuotaServiceTests
|
||||
var conferred = new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false);
|
||||
var svc = Build(UsageRepo(("20260707", 1)), resolutionResolver: _ => conferred, purchasedSlotsResolver: _ => 3);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal("pro", d.Plan);
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
@@ -625,7 +700,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro,
|
||||
mcpUserPlanOverride: "power", purchasedSlotsResolver: _ => 2);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal("power", d.Plan);
|
||||
Assert.Equal(10600, d.DailyLimit);
|
||||
@@ -639,7 +714,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"slotDailyRequestLimit":0,"slotMonthlyRequestLimit":0,"slotTenantDailyRequestLimit":0,"slotTenantMonthlyRequestLimit":0,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 5);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
Assert.Equal(3000, d.TenantDailyLimit);
|
||||
@@ -653,7 +728,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"slotDailyRequestLimit":50,"slotMonthlyRequestLimit":500,"slotTenantDailyRequestLimit":100,"slotTenantMonthlyRequestLimit":1000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 2);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1100, d.DailyLimit);
|
||||
Assert.Equal(21000, d.MonthlyLimit);
|
||||
@@ -668,7 +743,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1300, d.DailyLimit);
|
||||
Assert.Equal(3900, d.TenantDailyLimit);
|
||||
@@ -682,7 +757,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":0,"monthlyRequestLimit":20000,"tenantDailyRequestLimit":0,"tenantMonthlyRequestLimit":60000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 30000)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 4);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(0, d.DailyLimit);
|
||||
@@ -705,10 +780,10 @@ public class McpQuotaServiceTests
|
||||
{
|
||||
// Pro 1000 + 1 × 300 = 1300: the 1300th request of the day is the first blocked one.
|
||||
var below = Build(UsageRepo(("20260707", 1299)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
|
||||
Assert.True((await below.CheckAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await below.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
|
||||
var at = Build(UsageRepo(("20260707", 1300)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
|
||||
var d = await at.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await at.AdmitAsync(Oid, Upn, TenantId);
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("daily", d.Scope);
|
||||
Assert.Equal("user", d.Level);
|
||||
@@ -726,8 +801,8 @@ public class McpQuotaServiceTests
|
||||
.ReturnsAsync(new List<UserUsageRecord>());
|
||||
var svc = Build(repo);
|
||||
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
await svc.CheckAsync(OtherOid, "bob@contoso.com", TenantId);
|
||||
await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
await svc.AdmitAsync(OtherOid, "bob@contoso.com", TenantId);
|
||||
|
||||
repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageOverviewService;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The pure fold behind GET global/metrics/mcp-usage/overview: which numbers come from the quota counters and
|
||||
/// which from the telemetry, how platform admins drop out (but never out of a tenant's real organization
|
||||
/// windows), what a call costs, MCP-only tools, the daily trend and the per-minute ranking.
|
||||
/// </summary>
|
||||
public class McpUsageOverviewAssemblyTests
|
||||
{
|
||||
private const string TenantA = "aaaaaaaa-0000-0000-0000-00000000000a";
|
||||
private const string TenantB = "bbbbbbbb-0000-0000-0000-00000000000b";
|
||||
private const string Alice = "alice@contoso.com";
|
||||
private const string App = "app:11111111-2222-3333-4444-555555555555";
|
||||
private const string Operator = "ops@fabrikam.com";
|
||||
private const string Bob = "bob@fabrikam.com";
|
||||
private const string Carol = "carol@contoso.com";
|
||||
|
||||
// Window 3 days (Nov 3–5); the month started Nov 1, so Nov 1 rows count for the quota, not for the window.
|
||||
private static readonly Dates ThreeDays = new("20261103", "20261105", "20261101");
|
||||
private static readonly DateTime At = new(2026, 11, 5, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TenantUsageRecord Counter(string tenant, string oid, string upn, string day, long requests)
|
||||
=> new() { TenantId = tenant, UserId = oid, UserPrincipalName = upn, Date = day, RequestCount = requests, LastRequestAt = At };
|
||||
|
||||
private static readonly TenantUsageRecord[] Counters =
|
||||
{
|
||||
Counter(TenantA, "oid-alice", Alice, "20261101", 10),
|
||||
Counter(TenantA, "oid-alice", Alice, "20261104", 20),
|
||||
Counter(TenantA, "oid-alice", Alice, "20261105", 30),
|
||||
Counter(TenantA, "oid-app", App, "20261105", 5),
|
||||
Counter(TenantA, "oid-ops", Operator, "20261105", 100),
|
||||
Counter(TenantB, "oid-bob", Bob, "20261103", 7),
|
||||
};
|
||||
|
||||
private static CallRow Call(string day, string tool, string tenant, string principal, long calls, long served, long requests, string role)
|
||||
=> new(day, tool, tenant, principal, calls, served, requests, At, role);
|
||||
|
||||
private static readonly CallRow[] Calls =
|
||||
{
|
||||
Call("20261104", "get_session_summary", TenantA, Alice, 2, 2, 10, "Admin"),
|
||||
Call("20261105", "search_events", TenantA, Alice, 1, 1, 14, "Admin"),
|
||||
Call("20261105", "get_session_events", TenantA, Alice, 3, 3, 3, "Admin"),
|
||||
Call("20261105", "list_tenants", TenantA, Alice, 1, 1, 1, "Admin"),
|
||||
Call("20261105", "get_session_summary", TenantA, App, 1, 1, 5, "Viewer"),
|
||||
Call("20261105", "query_table", TenantA, Operator, 50, 50, 50, "GlobalAdmin"),
|
||||
Call("20261103", "search_sessions", TenantB, Bob, 2, 2, 7, "Admin"),
|
||||
Call("20261105", "get_time_attribution", TenantA, Carol, 4, 0, 0, "Admin"),
|
||||
};
|
||||
|
||||
private static McpQuotaDecision State(string plan, int daily, int monthly, long dailyUsed, long monthlyUsed)
|
||||
=> new() { Allowed = true, Plan = plan, DailyLimit = daily, MonthlyLimit = monthly, DailyUsed = dailyUsed, MonthlyUsed = monthlyUsed };
|
||||
|
||||
private static TelemetryResult Telemetry(bool available = true) => !available
|
||||
? new TelemetryResult(false, false, false, new(), new(), new(), new(), new(), new(), new ServerFacts(null, null))
|
||||
: new TelemetryResult(
|
||||
RequestsAvailable: true, McpLogAvailable: true, Partial: false,
|
||||
Calls: Calls.ToList(),
|
||||
Routes: new()
|
||||
{
|
||||
new("get_session_summary", "GET sessions/{id}/events", 3),
|
||||
new("get_session_summary", "GET sessions/{id}", 3),
|
||||
},
|
||||
Refusals: new()
|
||||
{
|
||||
new(RefusalQuota, "20261105", TenantA, Alice, "user", "daily", "community", 3, At, At.AddSeconds(9), 50, 50),
|
||||
new(RefusalMinute, "20261103", TenantB, Bob, "", "", "", 2, At, At, 120, 120),
|
||||
},
|
||||
Peaks: new()
|
||||
{
|
||||
new(PeakCalls, TenantA, Alice, 12, At),
|
||||
new(PeakRequests, TenantA, Alice, 40, At),
|
||||
new(PeakRequests, TenantB, Bob, 120, At),
|
||||
new(PeakCalls, TenantB, Bob, 5, At),
|
||||
},
|
||||
ToolQuality: new()
|
||||
{
|
||||
new("get_session_summary", 3, 0, 0, 0, 600, 19000.4),
|
||||
new("search_docs", 4, 1, 0, 0, 80, 5000),
|
||||
},
|
||||
McpRefusals: new() { new(Alice, 7, At) },
|
||||
ServerFacts: new ServerFacts(new DateTime(2026, 11, 4, 1, 0, 0, DateTimeKind.Utc), 60));
|
||||
|
||||
private static GetGlobalMcpUsageOverviewResponse Overview(bool includePlatformAdmins = false, bool telemetry = true)
|
||||
{
|
||||
var result = Telemetry(telemetry);
|
||||
var facts = new Dictionary<(string Tenant, string Principal), AccountFacts>
|
||||
{
|
||||
[(TenantA, Alice)] = new("oid-alice", null, State("community", 50, 250, 30, 60)),
|
||||
[(TenantA, App)] = new("oid-app", null, State("community", 50, 250, 5, 5)),
|
||||
[(TenantA, Operator)] = new("oid-ops", "GlobalAdmin", State("community", 50, 250, 100, 100)),
|
||||
[(TenantB, Bob)] = new("oid-bob", null, State("pro", 500, 6000, 0, 7)),
|
||||
};
|
||||
var limits = new Dictionary<string, McpQuotaService.TenantPlanLimits>
|
||||
{
|
||||
[TenantA] = new("community", 100, 250),
|
||||
[TenantB] = new("pro", 1500, 15000),
|
||||
};
|
||||
// TenantB has no readable configuration: no name.
|
||||
var names = new Dictionary<string, string> { [TenantA] = "contoso.com" };
|
||||
var minute = new Dictionary<(string Tenant, string Principal), int> { [(TenantA, Alice)] = 120, [(TenantB, Bob)] = 150 };
|
||||
|
||||
return Assemble(new OverviewInputs(ThreeDays, includePlatformAdmins, Counters, facts, limits, names, LatestRoles(result.Calls), minute, result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformAdmins_DropOutOfEveryNumber_ButNotOutOfTheOrganizationWindows()
|
||||
{
|
||||
var overview = Overview();
|
||||
var tenantA = overview.Tenants.Single(t => t.TenantId == TenantA);
|
||||
|
||||
Assert.DoesNotContain(tenantA.Accounts, a => a.Principal == Operator);
|
||||
Assert.DoesNotContain(overview.Tools, t => t.Tool == "query_table");
|
||||
Assert.Equal(55, tenantA.Requests); // alice 20 + 30, app 5 — the operator's 100 left out
|
||||
// The organization windows stay the tenant's real counters: today 30 + 5 + 100, the month 10 + 20 + 30 + 5 + 100.
|
||||
Assert.Equal(135, tenantA.Quota.DailyUsed);
|
||||
Assert.Equal(165, tenantA.Quota.MonthlyUsed);
|
||||
Assert.Equal("community", tenantA.Quota.TenantPlan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformAdmins_WhenIncluded_AreMarked()
|
||||
{
|
||||
var tenantA = Overview(includePlatformAdmins: true).Tenants.Single(t => t.TenantId == TenantA);
|
||||
var op = tenantA.Accounts.Single(a => a.Principal == Operator);
|
||||
|
||||
Assert.True(op.IsPlatformAdmin);
|
||||
Assert.Equal("GlobalAdmin", op.Role);
|
||||
Assert.Equal(155, tenantA.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accounts_RequestsComeFromTheCounters_CallsFromTheTelemetry()
|
||||
{
|
||||
var tenants = Overview().Tenants;
|
||||
Assert.Equal(new[] { TenantA, TenantB }, tenants.Select(t => t.TenantId)); // most calls first
|
||||
var accounts = tenants[0].Accounts;
|
||||
Assert.Equal(new[] { Alice, Carol, App }, accounts.Select(a => a.Principal));
|
||||
|
||||
var alice = accounts[0];
|
||||
Assert.Equal("oid-alice", alice.UserId);
|
||||
Assert.Equal(7, alice.Calls);
|
||||
Assert.Equal(50, alice.Requests); // Nov 1 lies before the window
|
||||
Assert.Equal("20261105", alice.PeakDay);
|
||||
Assert.Equal(30, alice.PeakDayRequests);
|
||||
Assert.Equal(1, alice.QuotaHits);
|
||||
Assert.Equal("Admin", alice.Role);
|
||||
Assert.Equal(new[] { ("get_session_events", 3L), ("get_session_summary", 2L), ("list_tenants", 1L) },
|
||||
alice.TopTools.Select(t => (t.Tool, t.Calls)));
|
||||
Assert.Equal(("community", 50, 250, 30L, 60L),
|
||||
(alice.Quota!.Plan, alice.Quota.DailyLimit, alice.Quota.MonthlyLimit, alice.Quota.DailyUsed, alice.Quota.MonthlyUsed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnAccountKnownOnlyFromTheTelemetry_HasNoObjectIdAndNoQuota()
|
||||
{
|
||||
// Every request refused: no counter row, but the calls happened.
|
||||
var carol = Overview().Tenants.Single(t => t.TenantId == TenantA).Accounts.Single(a => a.Principal == Carol);
|
||||
|
||||
Assert.Null(carol.UserId);
|
||||
Assert.Null(carol.Quota);
|
||||
Assert.Equal(4, carol.Calls);
|
||||
Assert.Equal(0, carol.Requests);
|
||||
Assert.Null(carol.PeakDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tools_RankByCalls_CostPerServedCall_AndMcpOnlyToolsFromTheLog()
|
||||
{
|
||||
var tools = Overview().Tools;
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "get_time_attribution", "search_docs", "get_session_events", "get_session_summary", "search_sessions", "list_tenants", "search_events" },
|
||||
tools.Select(t => t.Tool));
|
||||
|
||||
var summary = tools.Single(t => t.Tool == "get_session_summary");
|
||||
Assert.Equal(3, summary.Calls);
|
||||
Assert.Equal(2, summary.Accounts);
|
||||
Assert.Equal(1, summary.Tenants);
|
||||
Assert.Equal(5.0, summary.RequestsPerCall); // (10 + 5) requests over 3 served calls
|
||||
Assert.Equal(0.0, summary.ErrorRate);
|
||||
Assert.Equal(600, summary.P95DurationMs);
|
||||
Assert.Equal(19000, summary.AvgResultChars);
|
||||
Assert.Equal(new[] { "GET sessions/{id}", "GET sessions/{id}/events" }, summary.Routes.Select(r => r.Route));
|
||||
|
||||
var docs = tools.Single(t => t.Tool == "search_docs");
|
||||
Assert.Equal(4, docs.Calls);
|
||||
Assert.Null(docs.Accounts);
|
||||
Assert.Null(docs.Tenants);
|
||||
Assert.Equal(0, docs.RequestsPerCall);
|
||||
Assert.Equal(0.25, docs.ErrorRate);
|
||||
|
||||
// Every call refused: no served call, no cost to report.
|
||||
Assert.Equal(0, tools.Single(t => t.Tool == "get_time_attribution").RequestsPerCall);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Daily_HasEveryDayOfTheWindow_WithCallsByPlan()
|
||||
{
|
||||
var daily = Overview().Daily;
|
||||
|
||||
Assert.Equal(new[] { "20261103", "20261104", "20261105" }, daily.Select(d => d.Date));
|
||||
var today = daily[2];
|
||||
Assert.Equal(10, today.Calls); // alice 5, app 1, carol 4 — the operator's 50 left out
|
||||
Assert.Equal(35, today.Requests); // alice 30, app 5
|
||||
Assert.Equal(3, today.Accounts);
|
||||
Assert.Equal(new[] { ("community", 10L) }, today.CallsByPlan.Select(p => (p.Plan, p.Calls)));
|
||||
Assert.Equal(new[] { ("pro", 2L) }, daily[0].CallsByPlan.Select(p => (p.Plan, p.Calls)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuotaHits_AndMinuteLimits_RankTheAccountsClosestToALimit()
|
||||
{
|
||||
var overview = Overview();
|
||||
|
||||
var hit = Assert.Single(overview.QuotaHits);
|
||||
Assert.Equal((Alice, "user", "daily", 50L, 50L, 3L), (hit.Principal, hit.Level, hit.Scope, hit.Limit, hit.Used, hit.Refused));
|
||||
|
||||
Assert.Equal(60, overview.MinuteLimits.McpServerLimit);
|
||||
var minute = overview.MinuteLimits.Accounts;
|
||||
Assert.Equal(new[] { Bob, Alice }, minute.Select(m => m.Principal)); // 120/150 before 40/120 and 12/60
|
||||
Assert.Equal((120L, 150, 2L), (minute[0].PeakRequests, minute[0].BackendLimit, minute[0].BackendRefused));
|
||||
Assert.Equal((TenantA, 12L, 7L, 0L), (minute[1].TenantId, minute[1].PeakCalls, minute[1].McpRefused, minute[1].BackendRefused));
|
||||
// The share the list ranks by, in whole percent: the higher of calls/MCP limit and requests/backend limit.
|
||||
Assert.Equal(new[] { 80, 33 }, minute.Select(m => m.SharePercent)); // 120/150; max(12/60, 40/120)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Totals_SumTheLists_OnceForEveryConsumer()
|
||||
{
|
||||
var totals = Overview().Totals;
|
||||
|
||||
Assert.Equal(18, totals.Calls); // every tool of the ranking, search_docs' 4 MCP-only calls included
|
||||
Assert.Equal(4, totals.McpOnlyCalls);
|
||||
Assert.Equal(7, totals.Tools);
|
||||
Assert.Equal(62, totals.Requests); // tenant A 55, tenant B 7 — the operator's 100 left out
|
||||
Assert.Equal(2, totals.Tenants);
|
||||
Assert.Equal(4, totals.Accounts); // alice, carol, the app, bob
|
||||
Assert.Equal(1, totals.ServicePrincipals);
|
||||
Assert.Equal(1, totals.QuotaHitAccounts); // alice's daily window
|
||||
Assert.Equal(2, totals.MinuteRefusedAccounts); // alice at the MCP server, bob at the backend
|
||||
Assert.Equal(2, totals.LimitAccounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Totals_WithoutTheirStore_AreAbsent_NotAPartialSum()
|
||||
{
|
||||
var totals = Overview(telemetry: false).Totals;
|
||||
|
||||
Assert.Null(totals.Calls);
|
||||
Assert.Null(totals.McpOnlyCalls);
|
||||
Assert.Equal(0, totals.Tools);
|
||||
Assert.Equal(62, totals.Requests); // the counters still answer
|
||||
Assert.Equal(3, totals.Accounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenants_CarryTheirDomain_WhenTheConfigurationNamesOne()
|
||||
{
|
||||
var tenants = Overview().Tenants;
|
||||
|
||||
Assert.Equal("contoso.com", tenants.Single(t => t.TenantId == TenantA).DomainName);
|
||||
Assert.Null(tenants.Single(t => t.TenantId == TenantB).DomainName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnreadableTenantConfiguration_LeavesOnlyTheNameOut()
|
||||
{
|
||||
var config = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
|
||||
{ CallBase = false };
|
||||
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantA)).ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantB)).ReturnsAsync(new TenantConfiguration { TenantId = TenantB, DomainName = "fabrikam.com" });
|
||||
// Only the configuration read is exercised; the other collaborators stay unused.
|
||||
var service = new McpUsageOverviewService(
|
||||
null!, null!, null!, null!, config.Object, null!, null!, null!, NullLogger<McpUsageOverviewService>.Instance, TimeProvider.System);
|
||||
|
||||
Assert.Null(await service.ReadTenantConfigurationAsync(TenantA));
|
||||
Assert.Equal("fabrikam.com", (await service.ReadTenantConfigurationAsync(TenantB))?.DomainName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, 30, false, null)]
|
||||
[InlineData("7", "include", 7, true, null)]
|
||||
[InlineData("90", "exclude", 90, false, null)]
|
||||
[InlineData("91", null, 0, false, "days must be between 1 and 90")]
|
||||
[InlineData("0", null, 0, false, "days must be between 1 and 90")]
|
||||
[InlineData("-5", null, 0, false, "days must be between 1 and 90")]
|
||||
[InlineData("7.5", null, 0, false, "days must be an integer")]
|
||||
[InlineData("30", "all", 0, false, "platformAdmins must be include or exclude")]
|
||||
public void OverviewQuery_DefaultsAndBounds(string? days, string? platformAdmins, int expectedDays, bool expectedInclude, string? expectedError)
|
||||
{
|
||||
var parsed = AutopilotMonitor.Functions.Functions.Metrics.McpUsageMetricsFunction.ParseOverviewQuery(days, platformAdmins);
|
||||
|
||||
Assert.Equal((expectedDays, expectedInclude, expectedError), parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_NamesTheMcpLogCoverage_AndATelemetryOutageEmptiesOnlyItsParts()
|
||||
{
|
||||
Assert.Equal("20261104", Overview().Telemetry.McpLogFrom);
|
||||
|
||||
var dark = Overview(telemetry: false);
|
||||
Assert.False(dark.Telemetry.RequestsAvailable);
|
||||
Assert.False(dark.Telemetry.McpLogAvailable);
|
||||
Assert.Empty(dark.Tools);
|
||||
Assert.Empty(dark.QuotaHits);
|
||||
// The counters still carry tenants, accounts, requests and the quota.
|
||||
var tenantA = dark.Tenants.Single(t => t.TenantId == TenantA);
|
||||
Assert.Equal(new[] { Alice, App }, tenantA.Accounts.Select(a => a.Principal).OrderBy(p => p, StringComparer.Ordinal));
|
||||
Assert.Equal(0, tenantA.Calls);
|
||||
Assert.Equal(55, tenantA.Requests);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The fixed KQL of the MCP usage overview: which rows it reads (the worker's request row, MCP only, no access
|
||||
/// probe), that it counts calls exactly, leaves platform admins out unless asked, matches log lines by their
|
||||
/// start, reads only fields the MCP server writes (tests/fixtures/mcp-log-lines, proven on the MCP side by
|
||||
/// usage-log-fields.test.ts) — and that the parsers read the result tables by column name.
|
||||
/// </summary>
|
||||
public class McpUsageTelemetryQueriesTests
|
||||
{
|
||||
private static readonly Window Customers = new(new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 11, 6, 0, 0, 0, DateTimeKind.Utc), IncludePlatformAdmins: false);
|
||||
private static readonly Window Everyone = Customers with { IncludePlatformAdmins = true };
|
||||
|
||||
private static IEnumerable<string> RequestQueries(Window w) => new[] { Calls(w), Routes(w), Refusals(w), Peaks(w) };
|
||||
private static IEnumerable<string> McpLogQueries(Window w) => new[] { ToolQuality(w), McpRefusals(w), McpServerFacts(w) };
|
||||
|
||||
[Fact]
|
||||
public void Window_CoversWholeUtcDays()
|
||||
{
|
||||
Assert.Equal("datetime(2026-10-07T00:00:00Z)", Customers.From);
|
||||
Assert.Equal("datetime(2026-11-06T00:00:00Z)", Customers.To);
|
||||
Assert.Equal("P31D", Customers.Timespan); // the where clauses bound the window; the timespan only has to cover it
|
||||
foreach (var query in RequestQueries(Customers).Concat(McpLogQueries(Customers)))
|
||||
{
|
||||
Assert.Contains(">= datetime(2026-10-07T00:00:00Z)", query);
|
||||
Assert.Contains("< datetime(2026-11-06T00:00:00Z)", query);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestQueries_ReadTheWorkerRow_McpOnly_WithoutTheAccessProbe()
|
||||
{
|
||||
foreach (var query in RequestQueries(Customers))
|
||||
{
|
||||
Assert.Contains("customDimensions.Source) == 'WorkerMiddleware'", query);
|
||||
Assert.Contains("customDimensions.ClientSource) =~ 'mcp'", query);
|
||||
Assert.Contains("customDimensions.FunctionName) != 'GlobalAccessProbe'", query);
|
||||
Assert.Contains("customDimensions.McpToolName", query);
|
||||
Assert.Contains("customDimensions.CorrelationId", query);
|
||||
Assert.Contains("tolower(tostring(customDimensions.TenantId))", query);
|
||||
Assert.Contains("tolower(tostring(customDimensions.UserId))", query);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calls_AreCountedOncePerCorrelationId_NeverEstimated()
|
||||
{
|
||||
var query = Calls(Customers);
|
||||
|
||||
Assert.Contains("by corr, tool, tenant, principal", query);
|
||||
Assert.Contains("calls = count()", query);
|
||||
foreach (var q in RequestQueries(Customers).Concat(McpLogQueries(Customers)))
|
||||
Assert.DoesNotContain("dcount(", q);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformAdmins_AreLeftOut_UnlessIncluded()
|
||||
{
|
||||
foreach (var query in RequestQueries(Customers))
|
||||
Assert.Contains("role !in ('GlobalAdmin', 'GlobalReader')", query);
|
||||
Assert.Contains("tostring(d.scope) != 'ga'", ToolQuality(Customers));
|
||||
|
||||
foreach (var query in RequestQueries(Everyone).Concat(McpLogQueries(Everyone)))
|
||||
{
|
||||
Assert.DoesNotContain("'GlobalAdmin'", query);
|
||||
Assert.DoesNotContain("!= 'ga'", query);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refusals_AreClassifiedByTheLinesTheMiddlewaresWrite()
|
||||
{
|
||||
var query = Refusals(Customers);
|
||||
|
||||
Assert.Contains($"startswith '{McpQuotaEnforcementMiddleware.BlockedLogPrefix}'", query);
|
||||
Assert.Contains($"startswith '{UserRateLimitMiddleware.ThrottledLogPrefix}'", query);
|
||||
Assert.Equal("[McpQuota] BLOCKED", McpQuotaEnforcementMiddleware.BlockedLogPrefix);
|
||||
Assert.Equal("[UserRateLimit] THROTTLED", UserRateLimitMiddleware.ThrottledLogPrefix);
|
||||
Assert.Contains("join kind=inner lines on operation_Id", query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Peaks_SlideASixtySecondWindowInTenSecondSteps()
|
||||
{
|
||||
var query = Peaks(Customers);
|
||||
|
||||
Assert.Contains("bin(timestamp, 10s)", query);
|
||||
Assert.Contains("bin(at, 10s)", query);
|
||||
Assert.Equal(2, Regex.Matches(query, Regex.Escape("mv-expand k = range(0, 5, 1)")).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpLogLines_AreMatchedByTheirStart_NeverByHas()
|
||||
{
|
||||
Assert.Contains($"Log_s startswith '{ToolCallLinePrefix}'", ToolQuality(Customers));
|
||||
Assert.Contains($"Log_s startswith '{McpRefusalLinePrefix}'", McpRefusals(Customers));
|
||||
Assert.Contains($"Log_s startswith '{BootLinePrefix}'", McpServerFacts(Customers));
|
||||
foreach (var query in McpLogQueries(Customers))
|
||||
Assert.DoesNotContain("Log_s has", query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpLogQueries_ReadOnlyWhatTheServerWrites()
|
||||
{
|
||||
using var contract = JsonDocument.Parse(File.ReadAllText(Path.Combine(FindRepoRoot(), "tests", "fixtures", "mcp-log-lines", "fields.json")));
|
||||
var prefixes = contract.RootElement.GetProperty("prefixes");
|
||||
var fields = contract.RootElement.GetProperty("fields");
|
||||
|
||||
Assert.Equal(ToolCallLinePrefix, prefixes.GetProperty("tool_call").GetString());
|
||||
Assert.Equal(BootLinePrefix, prefixes.GetProperty("boot").GetString());
|
||||
Assert.Equal(McpRefusalLinePrefix, prefixes.GetProperty("mcp_refusal").GetString());
|
||||
|
||||
var toolCallFields = fields.GetProperty("tool_call").EnumerateArray().Select(f => f.GetString()!).ToHashSet();
|
||||
var readFromToolCall = Regex.Matches(ToolQuality(Everyone), @"\bd\.(\w+)").Select(m => m.Groups[1].Value).ToHashSet();
|
||||
Assert.NotEmpty(readFromToolCall);
|
||||
Assert.Subset(toolCallFields, readFromToolCall);
|
||||
|
||||
var bootFields = fields.GetProperty("boot").EnumerateArray().Select(f => f.GetString()!).ToHashSet();
|
||||
var readFromBoot = Regex.Matches(McpServerFacts(Everyone), @"parse_json\(Log_s\)\.(\w+)").Select(m => m.Groups[1].Value).ToHashSet();
|
||||
Assert.Equal(new[] { "rateLimitPerMinute" }, readFromBoot);
|
||||
Assert.Subset(bootFields, readFromBoot);
|
||||
}
|
||||
|
||||
// ── Parsers ──
|
||||
|
||||
private static KqlTable Table(string[] columns, params object?[][] rows)
|
||||
=> new()
|
||||
{
|
||||
Name = "PrimaryResult",
|
||||
Columns = columns.Select(c => new KqlColumn { Name = c, Type = "dynamic" }).ToList(),
|
||||
Rows = rows.Select(r => (IReadOnlyList<JsonElement>)r.Select(cell => JsonSerializer.SerializeToElement(cell)).ToList()).ToList(),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ParseCalls_ReadsByColumnName_WhateverTheOrder()
|
||||
{
|
||||
var table = Table(
|
||||
new[] { "role", "charged", "day", "principal", "tool", "calls", "tenant", "servedCalls", "firstAt" },
|
||||
new object?[] { "Admin", 59L, "20261028", "user@contoso.com", "get_time_attribution", 64L, "t1", 59L, "2026-10-28T16:12:30.4442709Z" });
|
||||
|
||||
var row = Assert.Single(ParseCalls(table));
|
||||
|
||||
Assert.Equal(new CallRow("20261028", "get_time_attribution", "t1", "user@contoso.com", 64, 59, 59,
|
||||
new DateTime(2026, 10, 28, 16, 12, 30, 444, DateTimeKind.Utc).AddTicks(2709), "Admin"), row);
|
||||
Assert.Equal(DateTimeKind.Utc, row.LastAt!.Value.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRefusals_KeepsMissingCountsMissing()
|
||||
{
|
||||
var table = Table(
|
||||
new[] { "gate", "day", "tenant", "principal", "level", "scope", "plan", "refused", "firstAt", "lastAt", "usedCount", "limitValue" },
|
||||
new object?[] { "minute", "20261026", "t1", "user@contoso.com", "", "", "", 2L, "2026-10-26T07:55:42Z", "2026-10-26T07:55:43Z", null, null });
|
||||
|
||||
var row = Assert.Single(ParseRefusals(table));
|
||||
|
||||
Assert.Equal("minute", row.Gate);
|
||||
Assert.Equal(2, row.Refused);
|
||||
Assert.Null(row.Used);
|
||||
Assert.Null(row.Limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseToolQuality_RoundsAFractionalCount_AndKeepsTheMean()
|
||||
{
|
||||
var table = Table(
|
||||
new[] { "tool", "calls", "errors", "cancelled", "overCap", "p95Ms", "avgChars" },
|
||||
new object?[] { "search_events", 26L, 1L, 0L, 0L, 1029.6, 10602.5 });
|
||||
|
||||
var row = Assert.Single(ParseToolQuality(table));
|
||||
|
||||
Assert.Equal(26, row.Calls);
|
||||
Assert.Equal(1030, row.P95Ms);
|
||||
Assert.Equal(10602.5, row.AvgChars);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseServerFacts_WithoutABootLine_HasNoLimit()
|
||||
{
|
||||
var facts = ParseServerFacts(Table(new[] { "oldest", "rateLimitPerMinute" }, new object?[] { "2026-10-07T01:52:43.167992Z", null }));
|
||||
|
||||
Assert.Equal(new DateTime(2026, 10, 7, 1, 52, 43, DateTimeKind.Utc).AddTicks(1_679_920), facts.Oldest);
|
||||
Assert.Null(facts.RateLimitPerMinute);
|
||||
Assert.Equal(new ServerFacts(null, null), ParseServerFacts(Table(new[] { "oldest", "rateLimitPerMinute" })));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -108,16 +108,10 @@ public class MetricsFilterInjectionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTenantUsageFilter_TenantInjection_StaysInsideLiteral()
|
||||
public void BuildUserUsageFilter_BothDates_NormalizeTheShapeAndBoundTheRange()
|
||||
{
|
||||
var filter = TableUserUsageRepository.BuildTenantUsageFilter("' or TenantId ne '", null, null);
|
||||
var filter = TableUserUsageRepository.BuildUserUsageFilter("user1", "2026-11-01", "2026-11-05");
|
||||
|
||||
Assert.Equal("TenantId eq ''' or TenantId ne '''", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTenantUsageFilter_NoScope_ReturnsNull()
|
||||
{
|
||||
Assert.Null(TableUserUsageRepository.BuildTenantUsageFilter(null, null, null));
|
||||
Assert.Equal("PartitionKey eq 'user1' and Date ge '20261101' and Date le '20261105'", filter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,64 +548,6 @@ public class MetricsWireParityTests
|
||||
new GetMcpUserUsageResponse { UserId = userId, Records = records });
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsage ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageResponse_matches_the_tenant_usage_shape()
|
||||
{
|
||||
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var records = SampleUsageRecords("9cc42e33-0004-4b7c-a1d2-52f3aaaa0010");
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, records },
|
||||
new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageResponse_omits_a_null_tenantId()
|
||||
{
|
||||
string? tenantId = null;
|
||||
var records = new List<UserUsageRecord>();
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, records },
|
||||
new GetGlobalMcpUsageResponse { TenantId = null, Records = records });
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsageDaily ----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageDailyResponse_matches_the_daily_summary_shape()
|
||||
{
|
||||
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var summaries = new List<UserUsageDailySummary>
|
||||
{
|
||||
new UserUsageDailySummary
|
||||
{
|
||||
Date = "20260830",
|
||||
TenantId = tenantId,
|
||||
TotalRequests = 321,
|
||||
UniqueUsers = 4,
|
||||
UniqueEndpoints = 11,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, summaries },
|
||||
new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageDailyResponse_omits_a_null_tenantId()
|
||||
{
|
||||
string? tenantId = null;
|
||||
var summaries = new List<UserUsageDailySummary>();
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, summaries },
|
||||
new GetGlobalMcpUsageDailyResponse { TenantId = null, Summaries = summaries });
|
||||
}
|
||||
|
||||
// ---- MetricsSummary / MetricsSummaryGlobal -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
@@ -832,6 +774,250 @@ public class MetricsWireParityTests
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsageOverview ------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageOverviewResponse_matches_the_overview_shape()
|
||||
{
|
||||
// One tenant with one account, one tool with one route, one quota refusal, one per-minute row — every
|
||||
// optional slot filled, so every key is pinned in its wire position.
|
||||
var at = new DateTime(2026, 11, 5, 10, 0, 0, DateTimeKind.Utc);
|
||||
var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001";
|
||||
var quota = new McpOrganizationQuotaNode { TenantPlan = "community", DailyLimit = 100, MonthlyLimit = 250, DailyUsed = 35, MonthlyUsed = 60 };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
dateFrom = "20261103",
|
||||
dateTo = "20261105",
|
||||
includesPlatformAdmins = false,
|
||||
telemetry = new { requestsAvailable = true, mcpLogAvailable = true, mcpLogFrom = "20261104", partial = true },
|
||||
totals = new
|
||||
{
|
||||
calls = 10L, mcpOnlyCalls = 4L, tools = 2, requests = 50L, tenants = 1, accounts = 1, servicePrincipals = 0,
|
||||
quotaHitAccounts = 1, minuteRefusedAccounts = 1, limitAccounts = 1,
|
||||
},
|
||||
daily = new[] { new { date = "20261105", calls = 10L, requests = 35L, accounts = 3, callsByPlan = new[] { new { plan = "community", calls = 10L } } } },
|
||||
tenants = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
domainName = "contoso.com",
|
||||
quota,
|
||||
calls = 7L,
|
||||
requests = 50L,
|
||||
quotaHits = 1,
|
||||
lastRequestAt = at,
|
||||
accounts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
userId = "oid-1",
|
||||
principal = "admin@contoso.com",
|
||||
role = "Admin",
|
||||
isPlatformAdmin = false,
|
||||
quota = new { plan = "community", dailyLimit = 50, monthlyLimit = 250, dailyUsed = 30L, monthlyUsed = 60L },
|
||||
calls = 7L,
|
||||
requests = 50L,
|
||||
peakDay = "20261105",
|
||||
peakDayRequests = 30L,
|
||||
quotaHits = 1,
|
||||
topTools = new[] { new { tool = "get_session_events", calls = 3L } },
|
||||
lastRequestAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tool = "get_session_summary",
|
||||
calls = 3L,
|
||||
accounts = 2,
|
||||
tenants = 1,
|
||||
requestsPerCall = 5.0,
|
||||
errorRate = 0.0,
|
||||
p95DurationMs = 600L,
|
||||
avgResultChars = 19000L,
|
||||
overCapCalls = 0L,
|
||||
routes = new[] { new { route = "GET sessions/{id}", requests = 3L } },
|
||||
},
|
||||
},
|
||||
quotaHits = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
date = "20261105", tenantId, principal = "admin@contoso.com", level = "user", scope = "daily", plan = "community",
|
||||
limit = 50L, used = 50L, refused = 3L, firstAt = at, lastAt = at,
|
||||
},
|
||||
},
|
||||
minuteLimits = new
|
||||
{
|
||||
mcpServerLimit = 60,
|
||||
accounts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId, principal = "admin@contoso.com", sharePercent = 33,
|
||||
peakCalls = 12L, peakCallsAt = at, mcpRefused = 7L, mcpLastRefusedAt = at,
|
||||
peakRequests = 40L, peakRequestsAt = at, backendLimit = 120, backendRefused = 2L, backendLastRefusedAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new GetGlobalMcpUsageOverviewResponse
|
||||
{
|
||||
DateFrom = "20261103",
|
||||
DateTo = "20261105",
|
||||
IncludesPlatformAdmins = false,
|
||||
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = true, McpLogAvailable = true, McpLogFrom = "20261104", Partial = true },
|
||||
Totals = new McpUsageTotalsNode
|
||||
{
|
||||
Calls = 10, McpOnlyCalls = 4, Tools = 2, Requests = 50, Tenants = 1, Accounts = 1, ServicePrincipals = 0,
|
||||
QuotaHitAccounts = 1, MinuteRefusedAccounts = 1, LimitAccounts = 1,
|
||||
},
|
||||
Daily = new List<McpUsageDayItem>
|
||||
{
|
||||
new() { Date = "20261105", Calls = 10, Requests = 35, Accounts = 3, CallsByPlan = new List<McpUsagePlanCallsItem> { new() { Plan = "community", Calls = 10 } } },
|
||||
},
|
||||
Tenants = new List<McpUsageTenantItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DomainName = "contoso.com",
|
||||
Quota = quota,
|
||||
Calls = 7,
|
||||
Requests = 50,
|
||||
QuotaHits = 1,
|
||||
LastRequestAt = at,
|
||||
Accounts = new List<McpUsageAccountItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UserId = "oid-1",
|
||||
Principal = "admin@contoso.com",
|
||||
Role = "Admin",
|
||||
IsPlatformAdmin = false,
|
||||
Quota = new McpUsageAccountQuotaNode { Plan = "community", DailyLimit = 50, MonthlyLimit = 250, DailyUsed = 30, MonthlyUsed = 60 },
|
||||
Calls = 7,
|
||||
Requests = 50,
|
||||
PeakDay = "20261105",
|
||||
PeakDayRequests = 30,
|
||||
QuotaHits = 1,
|
||||
TopTools = new List<McpUsageToolCallsItem> { new() { Tool = "get_session_events", Calls = 3 } },
|
||||
LastRequestAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Tools = new List<McpUsageToolItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Tool = "get_session_summary",
|
||||
Calls = 3,
|
||||
Accounts = 2,
|
||||
Tenants = 1,
|
||||
RequestsPerCall = 5.0,
|
||||
ErrorRate = 0.0,
|
||||
P95DurationMs = 600,
|
||||
AvgResultChars = 19000,
|
||||
OverCapCalls = 0,
|
||||
Routes = new List<McpUsageRouteItem> { new() { Route = "GET sessions/{id}", Requests = 3 } },
|
||||
},
|
||||
},
|
||||
QuotaHits = new List<McpUsageQuotaHitItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Date = "20261105", TenantId = tenantId, Principal = "admin@contoso.com", Level = "user", Scope = "daily", Plan = "community",
|
||||
Limit = 50, Used = 50, Refused = 3, FirstAt = at, LastAt = at,
|
||||
},
|
||||
},
|
||||
MinuteLimits = new McpUsageMinuteLimits
|
||||
{
|
||||
McpServerLimit = 60,
|
||||
Accounts = new List<McpUsageMinuteItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId, Principal = "admin@contoso.com", SharePercent = 33,
|
||||
PeakCalls = 12, PeakCallsAt = at, McpRefused = 7, McpLastRefusedAt = at,
|
||||
PeakRequests = 40, PeakRequestsAt = at, BackendLimit = 120, BackendRefused = 2, BackendLastRefusedAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageOverviewResponse_omits_what_a_store_did_not_answer()
|
||||
{
|
||||
// Telemetry down, an account known only from the telemetry shape (no object id, no quota), an MCP-only
|
||||
// tool (no reach, no quality), no MCP limit reported yet: every nullable key vanishes.
|
||||
var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001";
|
||||
var quota = new McpOrganizationQuotaNode { TenantPlan = "community", DailyLimit = 100, MonthlyLimit = 250, DailyUsed = 0, MonthlyUsed = 0 };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
dateFrom = "20261105",
|
||||
dateTo = "20261105",
|
||||
includesPlatformAdmins = true,
|
||||
telemetry = new { requestsAvailable = false, mcpLogAvailable = false },
|
||||
totals = new { tools = 1, requests = 0L, tenants = 1, accounts = 1, servicePrincipals = 1, quotaHitAccounts = 0, minuteRefusedAccounts = 0, limitAccounts = 0 },
|
||||
daily = Array.Empty<object>(),
|
||||
tenants = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
quota,
|
||||
calls = 4L,
|
||||
requests = 0L,
|
||||
quotaHits = 0,
|
||||
accounts = new[]
|
||||
{
|
||||
new { principal = "app:11111111-2222-3333-4444-555555555555", isPlatformAdmin = false, calls = 4L, requests = 0L, quotaHits = 0, topTools = Array.Empty<object>() },
|
||||
},
|
||||
},
|
||||
},
|
||||
tools = new[] { new { tool = "search_docs", calls = 4L, requestsPerCall = 0.0, routes = Array.Empty<object>() } },
|
||||
quotaHits = Array.Empty<object>(),
|
||||
minuteLimits = new { accounts = Array.Empty<object>() },
|
||||
},
|
||||
new GetGlobalMcpUsageOverviewResponse
|
||||
{
|
||||
DateFrom = "20261105",
|
||||
DateTo = "20261105",
|
||||
IncludesPlatformAdmins = true,
|
||||
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = false, McpLogAvailable = false },
|
||||
Totals = new McpUsageTotalsNode { Tools = 1, Requests = 0, Tenants = 1, Accounts = 1, ServicePrincipals = 1 },
|
||||
Daily = new List<McpUsageDayItem>(),
|
||||
Tenants = new List<McpUsageTenantItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Quota = quota,
|
||||
Calls = 4,
|
||||
Requests = 0,
|
||||
QuotaHits = 0,
|
||||
Accounts = new List<McpUsageAccountItem>
|
||||
{
|
||||
new() { Principal = "app:11111111-2222-3333-4444-555555555555", Calls = 4, TopTools = new List<McpUsageToolCallsItem>() },
|
||||
},
|
||||
},
|
||||
},
|
||||
Tools = new List<McpUsageToolItem> { new() { Tool = "search_docs", Calls = 4, RequestsPerCall = 0, Routes = new List<McpUsageRouteItem>() } },
|
||||
QuotaHits = new List<McpUsageQuotaHitItem>(),
|
||||
MinuteLimits = new McpUsageMinuteLimits { Accounts = new List<McpUsageMinuteItem>() },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetGrowthMetrics ---------------------------------------------------------------
|
||||
|
||||
private static GrowthMetricsResponse GrowthResponse(double? median, IReadOnlyList<GrowthTenantRow>? tenants) => new()
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains Newtonsoft.Json (D-420): new code reads and writes JSON with System.Text.Json. Every shipped
|
||||
/// source file under <c>src/</c> that uses Newtonsoft today is on a frozen baseline that may only shrink,
|
||||
/// and only the projects in <see cref="ProjectsWithPackage"/> may reference the package. Test projects are
|
||||
/// out of scope: they exercise Newtonsoft-bound code and never ship.
|
||||
/// A file uses Newtonsoft when a code line names the <c>Newtonsoft.Json</c> namespace (using, alias,
|
||||
/// fully qualified name, or the DLL name); comment lines do not count.
|
||||
/// </summary>
|
||||
public class NewtonsoftUsageGuardTests
|
||||
{
|
||||
private const string AgentWireAndState = "agent (net48): wire format and on-device state, which move only with an agent release";
|
||||
private const string DecisionCore = "DecisionCore serialization shared by the agent and its tests";
|
||||
private const string SummaryDialog = "summary dialog (net48): reads the agent's final status and checks the DLL ships beside it";
|
||||
private const string Ingest = "agent ingest wire (D-200)";
|
||||
private const string ConfigRules = "Newtonsoft-bound configuration and rule documents (PopulateObject)";
|
||||
private const string QueueEnvelope = "queue envelope contract shared by producers and consumers";
|
||||
private const string StoredJson = "stored JSON (table columns, blobs, report files) that existing rows must keep reading";
|
||||
private const string OutgoingPayload = "outgoing notification payloads";
|
||||
private const string RuleTemplates = "rule template interpolation over Newtonsoft tokens";
|
||||
|
||||
/// <summary>Path relative to <c>src/</c> → why it may still use Newtonsoft. Only shrinks.</summary>
|
||||
private static readonly Dictionary<string, string> Baseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BuiltInConfigCatalog.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashDumpCapture.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashRecord.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/PendingCrashReporter.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EventRecordWatermark.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/MdmRebootPolicyTracker.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleStatePersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/BandwidthStatePersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OsBuildChangeHost.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Persistence/StartupEventGate.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequencePersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Signals/TelemetrySignalEmitter.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Transitions/TelemetryTransitionEmitter.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/BackendTelemetryUploader.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemSerializer.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadCursorPersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2/Program.ImeMatchingMode.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs"] = AgentWireAndState,
|
||||
|
||||
["Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs"] = DecisionCore,
|
||||
["Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs"] = DecisionCore,
|
||||
["Shared/AutopilotMonitor.DecisionCore/Serialization/StateSerializer.cs"] = DecisionCore,
|
||||
["Shared/AutopilotMonitor.DecisionCore/Serialization/TransitionSerializer.cs"] = DecisionCore,
|
||||
["Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs"] = DecisionCore,
|
||||
|
||||
["Agent/AutopilotMonitor.SummaryDialog/App.xaml.cs"] = SummaryDialog,
|
||||
["Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml.cs"] = SummaryDialog,
|
||||
["Agent/AutopilotMonitor.SummaryDialog/Models/FinalStatus.cs"] = SummaryDialog,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Ingest/EventDataNormalizer.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Ingest/TelemetryPayloadParser.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Ime/ImePatternHealthService.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityInventoryLoader.cs"] = Ingest,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Config/PatchAdminConfigurationFunction.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Helpers/RequestBody.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/BuiltInGatherRules.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/BuiltInImeLogPatterns.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/GitHubRuleRepository.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/RulesWrapper.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs"] = ConfigRules,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Services/Activation/AzureQueueTenantAutoApproveEnqueuer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Analyze/AzureQueueAnalyzeOnEnrollmentEndProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Backup/Queue/AzureQueueCriticalTableBackupProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Backup/Queue/CriticalTableBackupQueueWorker.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Deletion/AzureQueueSessionDeletionMaintenanceTriggerProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionWorker.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Ime/AzureQueueImeMsiArchiveProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Maintenance/AzureQueueMaintenanceTriggerProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Notifications/AzureQueueNotificationDispatchProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Offboarding/AzureQueueTenantOffboardingEnqueuer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingWorker.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Queueing/QueueEnvelopeReader.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Queueing/QueuePollingWorker.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/AzureQueueVulnerabilityCorrelateProducer.cs"] = QueueEnvelope,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.ServerActions.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelateHandler.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs"] = StoredJson,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Services/Notifications/DiscordRenderer.cs"] = OutgoingPayload,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs"] = OutgoingPayload,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Notifications/SlackRenderer.cs"] = OutgoingPayload,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Notifications/TeamsWorkflowAdaptiveCardRenderer.cs"] = OutgoingPayload,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Services/RuleTemplateInterpolator.cs"] = RuleTemplates,
|
||||
};
|
||||
|
||||
/// <summary>The projects that may reference the Newtonsoft.Json package directly.</summary>
|
||||
private static readonly string[] ProjectsWithPackage =
|
||||
{
|
||||
"Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj",
|
||||
"Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj",
|
||||
"Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj",
|
||||
"Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj",
|
||||
"Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj",
|
||||
};
|
||||
|
||||
private static readonly Regex NamespaceUse = new(@"\bNewtonsoft\.Json\b", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex PackageReference = new(
|
||||
@"<PackageReference\s+Include\s*=\s*""Newtonsoft\.Json""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
[Fact]
|
||||
public void Newtonsoft_use_is_frozen_per_file()
|
||||
{
|
||||
var actual = ShippedSources()
|
||||
.Where(s => UsesNewtonsoft(s.Text))
|
||||
.Select(s => s.Relative)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var violations = new List<string>();
|
||||
foreach (var file in actual.OrderBy(f => f, StringComparer.Ordinal))
|
||||
{
|
||||
if (!Baseline.ContainsKey(file))
|
||||
violations.Add($"{file}: NEW use of Newtonsoft.Json — read and write JSON with System.Text.Json; the Newtonsoft baseline is frozen (D-420).");
|
||||
}
|
||||
foreach (var file in Baseline.Keys.OrderBy(f => f, StringComparer.Ordinal))
|
||||
{
|
||||
if (!actual.Contains(file))
|
||||
violations.Add($"{file}: on the baseline but no longer uses Newtonsoft.Json (or is gone) — remove its entry (ratchet down).");
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0, "Newtonsoft.Json baseline violated:\n " + string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Newtonsoft_package_only_in_known_projects()
|
||||
{
|
||||
var srcRoot = Path.Combine(FindRepoRoot(), "src");
|
||||
var actual = SourceFiles(srcRoot, "*.csproj", includeTestProjects: true)
|
||||
.Where(f => PackageReference.IsMatch(File.ReadAllText(f)))
|
||||
.Select(f => Relative(srcRoot, f))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var violations = actual.Except(ProjectsWithPackage, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(p => $"{p}: NEW Newtonsoft.Json package reference — use System.Text.Json (D-420).")
|
||||
.Concat(ProjectsWithPackage.Except(actual, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(p => $"{p}: listed but no longer references Newtonsoft.Json — remove it from ProjectsWithPackage (ratchet down)."))
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.True(violations.Count == 0, "Newtonsoft.Json package references violated:\n " + string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("using Newtonsoft.Json;", true)]
|
||||
[InlineData("using Newtonsoft.Json.Linq;", true)]
|
||||
[InlineData("using JsonConvert = Newtonsoft.Json.JsonConvert;", true)]
|
||||
[InlineData(" catch (Newtonsoft.Json.JsonException) { }", true)]
|
||||
[InlineData("var dll = Path.Combine(dir, \"Newtonsoft.Json.dll\");", true)]
|
||||
[InlineData("var url = \"https://x\"; Newtonsoft.Json.Linq.JObject.Parse(s); // trailing", true)]
|
||||
[InlineData("var x = 1; // Newtonsoft.Json is not used here", false)]
|
||||
[InlineData(" /// <summary>Newtonsoft.Json defaults on both sides.</summary>", false)]
|
||||
[InlineData("// Newtonsoft.Json", false)]
|
||||
[InlineData(" /* Newtonsoft.Json */", false)]
|
||||
[InlineData(" * Newtonsoft.Json in a block comment", false)]
|
||||
[InlineData("var rule = await RequestBody.ReadNewtonsoftAsync<AnalyzeRule>(req);", false)]
|
||||
public void Use_detector_sees_code_but_not_comments(string line, bool expected)
|
||||
=> Assert.Equal(expected, UsesNewtonsoft(line));
|
||||
|
||||
private static bool UsesNewtonsoft(string text)
|
||||
{
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
var trimmed = raw.TrimStart();
|
||||
if (trimmed.StartsWith("//", StringComparison.Ordinal) || trimmed.StartsWith("/*", StringComparison.Ordinal) || trimmed.StartsWith('*'))
|
||||
continue;
|
||||
var line = raw;
|
||||
var comment = line.IndexOf("//", StringComparison.Ordinal);
|
||||
if (comment >= 0 && !line[..comment].Contains('"'))
|
||||
line = line[..comment];
|
||||
if (NamespaceUse.IsMatch(line))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Relative, string Text)> ShippedSources()
|
||||
{
|
||||
var srcRoot = Path.Combine(FindRepoRoot(), "src");
|
||||
return SourceFiles(srcRoot, "*.cs", includeTestProjects: false)
|
||||
.Select(f => (Relative(srcRoot, f), File.ReadAllText(f)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks <paramref name="root"/> without entering build output or package folders: the web and MCP
|
||||
/// node_modules alone would make a plain recursive enumeration take tens of seconds.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> SourceFiles(string root, string pattern, bool includeTestProjects)
|
||||
{
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(root);
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var dir = pending.Pop();
|
||||
foreach (var file in Directory.EnumerateFiles(dir, pattern))
|
||||
yield return file;
|
||||
foreach (var sub in Directory.EnumerateDirectories(dir))
|
||||
{
|
||||
var name = Path.GetFileName(sub);
|
||||
if (SkippedFolders.Contains(name) || (!includeTestProjects && name.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
pending.Push(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> SkippedFolders = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", "node_modules", ".next" };
|
||||
|
||||
private static string Relative(string root, string path)
|
||||
=> Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/');
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// (otherwise admin SignalR group joins + admin-audience notifications regress), while a pure GlobalReader
|
||||
/// stays read-only. Also covers the cross-tenant gate keying off HasGlobalScope.
|
||||
/// </summary>
|
||||
public class PolicyEnforcementMiddlewareTests
|
||||
public partial class PolicyEnforcementMiddlewareTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
@@ -1201,16 +1201,17 @@ public class PolicyEnforcementMiddlewareTests
|
||||
|
||||
/// <summary>
|
||||
/// Operator reads whose ?tenantId= is a handler-side filter for a Global Admin / Global Reader: session reports
|
||||
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), a
|
||||
/// tenant's MCP usage (its accounts and their tools) and verdict calibration (classifier diagnostics, drift
|
||||
/// alerts). None of it is the managed tenant's telemetry, so a delegated (MSP) reader never reaches them.
|
||||
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), MCP
|
||||
/// usage (a tenant's organization budget by account, the all-tenant overview with every tenant's accounts and
|
||||
/// tools) and verdict calibration (classifier diagnostics, drift alerts). None of it is the managed tenant's
|
||||
/// telemetry, so a delegated (MSP) reader never reaches them.
|
||||
/// </summary>
|
||||
public static TheoryData<string> OperatorFilterReads => new()
|
||||
{
|
||||
"/api/global/session-reports",
|
||||
"/api/global/rule-submissions",
|
||||
"/api/global/metrics/mcp-usage",
|
||||
"/api/global/metrics/mcp-usage/daily",
|
||||
"/api/global/metrics/mcp-usage/organization",
|
||||
"/api/global/metrics/mcp-usage/overview",
|
||||
"/api/global/metrics/verdict-calibration",
|
||||
};
|
||||
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// "View as tenant": a Global Admin reads one tenant's portal as its member, read-only. DecideAsync admits the
|
||||
/// headers only for a Global Admin, refuses every write, and decides the rest with the SAME evaluators over an
|
||||
/// emulated member of the viewed tenant — the parity test pins that the emulation answers every read route
|
||||
/// exactly like a real member of that tenant.
|
||||
/// </summary>
|
||||
public partial class PolicyEnforcementMiddlewareTests
|
||||
{
|
||||
private const string GaUpn = "ga@vendor.example";
|
||||
|
||||
private static ViewAsTarget ViewB(string role = Constants.TenantRoles.Admin) => new(TenantB, role);
|
||||
|
||||
/// <summary>A Global Admin homed in TenantA (the default identity binding) and an onboarded TenantB.</summary>
|
||||
private static Harness ViewAsHarness()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.Repo.Setup(r => r.GetGlobalRoleAsync(GaUpn)).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantB))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantB });
|
||||
return h;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_GlobalAdmin_ReadsTheViewedTenantAsItsAdmin()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
var ctx = r.Context!;
|
||||
Assert.Equal(TenantB, ctx.TenantId);
|
||||
Assert.Equal(TenantB, ctx.TargetTenantId);
|
||||
Assert.False(ctx.IsGlobalAdmin);
|
||||
Assert.False(ctx.HasFleetScope);
|
||||
Assert.Null(ctx.AllowedTenantIds);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, ctx.UserRole);
|
||||
Assert.True(ctx.IsTenantAdmin);
|
||||
// Identity stays the real caller's: throttling, logs and the SignalR binding key off it.
|
||||
Assert.Equal(GaUpn, ctx.UserPrincipalName);
|
||||
Assert.Equal(new ViewAsContext(TenantA, Constants.TenantRoles.Admin), ctx.ViewAs);
|
||||
Assert.Equal(TenantA, ctx.IdentityTenantId());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tenant admin")]
|
||||
[InlineData("global reader")]
|
||||
[InlineData("delegated admin of the viewed tenant")]
|
||||
[InlineData("no role")]
|
||||
public async Task ViewAs_IsRefusedToEveryoneButAGlobalAdmin_AndReported(string caller)
|
||||
{
|
||||
const string upn = "someone@contoso.com";
|
||||
var h = ViewAsHarness();
|
||||
switch (caller)
|
||||
{
|
||||
case "tenant admin": h.AsTenantAdmin(TenantA, upn); break;
|
||||
case "global reader": h.Repo.Setup(r => r.GetGlobalRoleAsync(upn)).ReturnsAsync(Constants.GlobalRoles.GlobalReader); break;
|
||||
case "delegated admin of the viewed tenant": h.AsDelegated(TenantB, Constants.DelegatedRoles.DelegatedAdmin); break;
|
||||
}
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), viewAs: ViewB());
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal(403, r.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, r.ErrorCode);
|
||||
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r, viewAsRequested: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_ThroughMcpOrAForeignClientApp_IsRefusedEvenForAGlobalAdmin()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
var capped = AuthedPrincipal(TenantA, GaUpn);
|
||||
ClientAppBinding.MarkCapped(capped, "33333333-3333-3333-3333-333333333333");
|
||||
|
||||
var mcp = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), isMcpClient: true, viewAs: ViewB());
|
||||
var foreign = await h.Middleware.DecideAsync("GET", "/api/sessions", null, capped, viewAs: ViewB());
|
||||
|
||||
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, mcp.ErrorCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, foreign.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_AdmissionAnswers_ForMalformedHeaders_UnknownTenants_AndAnonymousCallers()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
|
||||
var malformed = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewAsTarget.Invalid);
|
||||
var unknown = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn),
|
||||
viewAs: new ViewAsTarget("33333333-3333-3333-3333-333333333333", Constants.TenantRoles.Admin));
|
||||
var anonymous = await h.Middleware.DecideAsync("GET", "/api/sessions", null, principal: null, viewAs: ViewB());
|
||||
|
||||
Assert.Equal((400, Constants.ApiErrorCodes.ViewAsInvalid), (malformed.StatusCode, malformed.ErrorCode));
|
||||
Assert.Equal((404, Constants.ApiErrorCodes.ViewAsTenantNotFound), (unknown.StatusCode, unknown.ErrorCode));
|
||||
Assert.Equal((401, Constants.ApiErrorCodes.AuthenticationRequired), (anonymous.StatusCode, anonymous.ErrorCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_RefusesWrites_RunsTheReadsDeclaredOnPostRoutes_AndRefusesTheGetThatStartsAnAction()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
var ga = AuthedPrincipal(TenantA, GaUpn);
|
||||
|
||||
// A test send would reach the customer's channels.
|
||||
var write = await h.Middleware.DecideAsync("POST", $"/api/config/{TenantB}/test-notification", null, ga, viewAs: ViewB());
|
||||
var join = await h.Middleware.DecideAsync("POST", "/api/realtime/groups/join", null, ga, viewAs: ViewB());
|
||||
var consentFlow = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}/autopilot-device-validation/consent-url", null, ga, viewAs: ViewB());
|
||||
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.ViewAsReadOnly), (write.StatusCode, write.ErrorCode));
|
||||
Assert.Equal(ViewAsTarget.ReadOnlyMessage, write.ErrorMessage);
|
||||
Assert.True(join.Allowed);
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.ViewAsReadOnly), (consentFlow.StatusCode, consentFlow.ErrorCode));
|
||||
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(write, viewAsRequested: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_ThePlatformSurface_IsClosedLikeForAMember_AndTheDenialIsNotReported()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.InsufficientPermissions), (r.StatusCode, r.ErrorCode));
|
||||
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r, viewAsRequested: true));
|
||||
// The same denial without view-as headers is the assume-breach report it always was.
|
||||
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_AnotherTenantsRoute_IsCrossTenantDenied_EvenTheGlobalAdminsHomeTenant()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
|
||||
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.CrossTenantAccessDenied), (r.StatusCode, r.ErrorCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_ASuspendedTenant_ShowsTheSuspension()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantB))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantB, Disabled = true, DisabledReason = "Suspended for review" });
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
|
||||
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.TenantSuspended), (r.StatusCode, r.ErrorCode));
|
||||
Assert.Equal("Suspended for review", r.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_AsViewer_ReadsTheTenantButStaysOutOfTheAdminTier()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
var ga = AuthedPrincipal(TenantA, GaUpn);
|
||||
|
||||
var sessions = await h.Middleware.DecideAsync("GET", "/api/sessions", null, ga, viewAs: ViewB(Constants.TenantRoles.Viewer));
|
||||
var adminTier = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}/notification-channel-health", null, ga,
|
||||
viewAs: ViewB(Constants.TenantRoles.Viewer));
|
||||
|
||||
Assert.True(sessions.Allowed);
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, sessions.Context!.UserRole);
|
||||
Assert.False(sessions.Context.IsTenantAdmin);
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.InsufficientPermissions), (adminTier.StatusCode, adminTier.ErrorCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_RoutesWithoutAJwtTier_IgnoreTheHeaders()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/health", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewAsTarget.Invalid);
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.Null(r.Context!.ViewAs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The core promise: what the Global Admin sees is what the member sees. For every route that runs in view-as
|
||||
/// mode and every tenant role, the emulated decision equals the decision for a real member of that role in the
|
||||
/// viewed tenant — admission, status, error code, role, admin flag, scope and tenants.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ViewAs_DecidesEveryReadRoute_ExactlyLikeARealMemberOfTheViewedTenant()
|
||||
{
|
||||
const string memberUpn = "member@tenant-b.example";
|
||||
const string memberOid = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
|
||||
var mismatches = new List<string>();
|
||||
var compared = 0;
|
||||
|
||||
foreach (var role in new[] { Constants.TenantRoles.Admin, Constants.TenantRoles.Operator, Constants.TenantRoles.Viewer })
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
h.Repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new TenantMember { Upn = memberUpn, TenantId = TenantB, Role = role, IsEnabled = true });
|
||||
|
||||
foreach (var entry in EndpointAccessPolicyCatalog.Entries)
|
||||
{
|
||||
if (entry.ViewAsAccess != ViewAsAccess.Read
|
||||
|| entry.Policy is EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth)
|
||||
continue;
|
||||
|
||||
var path = "/api/" + Regex.Replace(entry.RouteTemplate, @"\{([^}]+)\}",
|
||||
m => m.Groups[1].Value == "tenantId" ? TenantB : "x1");
|
||||
var member = await h.Middleware.DecideAsync(entry.HttpMethod, path, null, AuthedPrincipal(TenantB, memberUpn, memberOid));
|
||||
var viewed = await h.Middleware.DecideAsync(entry.HttpMethod, path, null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB(role));
|
||||
|
||||
compared++;
|
||||
var expected = Describe(member);
|
||||
var actual = Describe(viewed);
|
||||
if (expected != actual)
|
||||
mismatches.Add($"{role} {entry.HttpMethod} {entry.RouteTemplate}: member={expected} viewAs={actual}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(mismatches);
|
||||
Assert.True(compared > 300, $"only {compared} decisions compared — the catalog walk is broken");
|
||||
}
|
||||
|
||||
private static string Describe(PolicyEnforcementMiddleware.PolicyResult r)
|
||||
=> r.Allowed
|
||||
? $"allow role={r.Context!.UserRole} admin={r.Context.IsTenantAdmin} scope={r.Context.HasFleetScope} tenant={r.Context.TenantId} target={r.Context.TargetTenantId}"
|
||||
: $"deny {r.StatusCode} {r.ErrorCode}";
|
||||
}
|
||||
+5
-4
@@ -126,20 +126,21 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
[Fact]
|
||||
public async Task RunCore_emits_LongRunningSevere_when_body_runs_past_severe_threshold()
|
||||
{
|
||||
// Deterministic: the body blocks until the SEVERE watchdog records its OpsEvent. Warn has a
|
||||
// smaller threshold, so it is guaranteed to have fired first — neither depends on a body
|
||||
// sleep outlasting a fixed wall-clock threshold.
|
||||
// Deterministic: the body blocks until BOTH watchdogs have recorded their OpsEvents. The two
|
||||
// delays are independent timers, so under load the severe one can complete first; the cancel
|
||||
// in the finally would then drop a warn delay whose callback had not run yet.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMilliseconds(60));
|
||||
harness.SetKillSwitch(false);
|
||||
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionRetentionFanoutService.FanoutResult _, DateTime _, CancellationToken ct) =>
|
||||
{
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunningSevere");
|
||||
});
|
||||
|
||||
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
|
||||
|
||||
// Both watchdogs fire: warning first (smaller threshold), then severe (which the body waited for).
|
||||
// Both watchdogs fired while the body was still running.
|
||||
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunning");
|
||||
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunningSevere");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ratchet: a function takes the tenant whose data it reads from the request context — <c>TenantHelper.GetTenantId(req)</c>
|
||||
/// or <c>RequestContext.TenantId</c> — never from the token. Only those follow "view as tenant", where a Global Admin
|
||||
/// reads another tenant as its member; a data read off the token's <c>tid</c> would silently show the Global Admin's
|
||||
/// own tenant under the viewed tenant's name. The baseline lists the reads that mean the caller's IDENTITY; it only
|
||||
/// shrinks.
|
||||
/// </summary>
|
||||
public class TenantClaimReadGuardTests
|
||||
{
|
||||
private static readonly Regex TokenTenantRead = new(
|
||||
@"\.GetTenantId\(\)|FindFirst\(""tid""\)|identity/claims/tenantid", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Token-tenant reads that identify the caller: file → why.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Functions/Infrastructure/AuthFunction.cs"] =
|
||||
"auth/me identifies and onboards the sign-in tenant (its view-as branch runs first), the caller's own What's-new marker and Terms + DPA",
|
||||
["Functions/Infrastructure/McpUserFunction.cs"] = "the MCP front door binds the caller's identity",
|
||||
["Functions/Metrics/McpUsageMetricsFunction.cs"] = "the caller's own MCP usage (me)",
|
||||
["Functions/Rules/PreviewWhitelistFunction.cs"] = "the caller's own tenant's activation notification address",
|
||||
};
|
||||
|
||||
private static string FunctionsRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Functions_read_the_data_tenant_from_the_request_context_not_from_the_token()
|
||||
{
|
||||
var root = FunctionsRoot();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var offenders = new List<string>();
|
||||
|
||||
foreach (var sub in new[] { "Functions", "Pagination" })
|
||||
{
|
||||
foreach (var path in Directory.EnumerateFiles(Path.Combine(root, sub), "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
if (!TokenTenantRead.IsMatch(File.ReadAllText(path)))
|
||||
continue;
|
||||
var rel = Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||
seen.Add(rel);
|
||||
if (!Baseline.ContainsKey(rel))
|
||||
offenders.Add(rel);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"Token tenant read in a function — take the data tenant from TenantHelper.GetTenantId(req) or the request " +
|
||||
"context, or add the file to the baseline with the identity reason:\n " + string.Join("\n ", offenders));
|
||||
|
||||
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
Assert.True(stale.Count == 0,
|
||||
"Baseline entries without a token tenant read any more — remove them (the ratchet only shrinks):\n " +
|
||||
string.Join("\n ", stale));
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,6 @@ public class TypedRequestGuardTests
|
||||
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "agent route (D-200)",
|
||||
// Foreign schema: Azure Monitor common alert schema, not our contract.
|
||||
["Functions/Infrastructure/AzureMonitorAlertWebhookFunction.cs"] = "Azure Monitor alert schema",
|
||||
// Parses the UPSTREAM Kusto response, not the request body.
|
||||
["Functions/Raw/AppInsightsQueryFunction.cs"] = "upstream response parse",
|
||||
// Not request bodies at all: telemetry item payloads (agent wire, D-200) and stored table columns.
|
||||
["Functions/Ingest/TelemetryPayloadParser.cs"] = "telemetry item PayloadJson (agent wire, D-200)",
|
||||
["Functions/Config/ListTenantConfigBackupsFunction.cs"] = "stored backup diff column",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Tests.UpstreamParserReadings;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Differential test for the move of the Graph and Entra token-endpoint parsers to System.Text.Json (D-420):
|
||||
/// <see cref="UpstreamResponseCases"/> builds response bodies from a fixed seed per parser, and the production
|
||||
/// parser must yield exactly what the Newtonsoft reference in <see cref="LegacyUpstreamParsers"/> yields: same
|
||||
/// result, or both throw (every caller treats any escaped exception the same way).
|
||||
/// The bodies leave out the inputs of the deliberate differences D1 to D6; those are pinned in
|
||||
/// <see cref="GraphResponseParsingTests"/>. A body cut off mid-object (D7) is compared with the production
|
||||
/// parser's answer to an unreadable body instead: Newtonsoft's DeserializeObject read the part before the cut
|
||||
/// as if the open containers were closed.
|
||||
/// Temporary: removed with <see cref="LegacyUpstreamParsers"/> after a clean production run (backlog). The
|
||||
/// generator and the readings stay for <see cref="UpstreamJsonInvariantTests"/>.
|
||||
/// </summary>
|
||||
public class UpstreamJsonDifferentialTests
|
||||
{
|
||||
private const int CaseCount = 20_000;
|
||||
|
||||
[Fact]
|
||||
public void Token_response_matches_the_reference()
|
||||
=> Compare(seed: 4201, g => g.TokenBody(),
|
||||
b => Describe(GraphTokenService.ReadTokenResponse(b)),
|
||||
b => Describe(LegacyUpstreamParsers.ReadTokenResponse(b)),
|
||||
hit: o => o.StartsWith("token=\"", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Entra_rejection_matches_the_reference()
|
||||
=> Compare(seed: 4202, g => g.EntraBody(),
|
||||
b => Describe(EntraTokenRejection.Parse(400, b)),
|
||||
b => Describe(LegacyUpstreamParsers.ParseEntraRejection(400, b)),
|
||||
hit: o => o.Contains("code=\"AADSTS", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_matches_the_reference()
|
||||
=> Compare(seed: 4203, g => g.AutopilotBody(Serial),
|
||||
b => Describe(AutopilotDeviceValidator.FindRegistration(b, Serial)),
|
||||
b => Describe(LegacyUpstreamParsers.FindRegistration(b, Serial)),
|
||||
hit: o => o.StartsWith("id=", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Corporate_page_matches_the_reference()
|
||||
=> Compare(seed: 4204, g => g.CorporateBody(),
|
||||
b => Describe(CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId)),
|
||||
b => Describe(LegacyUpstreamParsers.ReadIdentityPage(b, CorporateId)),
|
||||
hit: o => o.StartsWith("found=True", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Association_list_matches_the_reference()
|
||||
=> Compare(seed: 4205, g => g.AssociationBody(Serial),
|
||||
b => Describe(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial)),
|
||||
b => Describe(LegacyUpstreamParsers.ParseAssociation(b, Serial)),
|
||||
hit: o => o.StartsWith("valid=True", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_matches_the_reference()
|
||||
=> Compare(seed: 4206, g => g.CloudPcBody(IntuneId),
|
||||
b => Describe(CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId)),
|
||||
b => Describe(LegacyUpstreamParsers.ParseCloudPc(b, IntuneId)),
|
||||
hit: o => o.StartsWith("valid=True", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_matches_the_reference()
|
||||
=> Compare(seed: 4207, g => g.ManagedDeviceBody(IntuneId),
|
||||
b => Describe(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId)),
|
||||
b => Describe(LegacyUpstreamParsers.ParseManagedDevice(b, IntuneId)),
|
||||
hit: o => !o.StartsWith("outcome=NotFound", StringComparison.Ordinal) && o != "<throws>");
|
||||
|
||||
[Fact]
|
||||
public void Script_page_matches_the_reference()
|
||||
=> Compare(seed: 4208, g => g.ScriptPageBody(),
|
||||
b => Describe(ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true)),
|
||||
b => Describe(LegacyUpstreamParsers.ReadScriptPage(b, withFileName: true)),
|
||||
hit: o => o.StartsWith("\"", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Script_name_matches_the_reference()
|
||||
=> Compare(seed: 4209, g => g.ScriptNameBody(),
|
||||
b => Describe(ScriptDisplayNameResolver.ReadScriptDisplayName(b)),
|
||||
b => Describe(LegacyUpstreamParsers.ReadScriptDisplayName(b)),
|
||||
hit: o => o != "<null>" && o != "<throws>");
|
||||
|
||||
private static void Compare(int seed, Func<UpstreamResponseCases, string> body, Func<string, string> actual, Func<string, string> reference, Func<string, bool> hit)
|
||||
{
|
||||
// The reference formats dates in the current culture before parsing them back; en-US keeps its
|
||||
// whole-second "Z" dates exact. The culture swap on other hosts is pinned in GraphResponseParsingTests.
|
||||
var saved = CultureInfo.CurrentCulture;
|
||||
CultureInfo.CurrentCulture = new CultureInfo("en-US");
|
||||
try
|
||||
{
|
||||
var generator = new UpstreamResponseCases(seed);
|
||||
var unreadable = Outcome(() => actual("not json"));
|
||||
var mismatches = new List<string>();
|
||||
int hits = 0, throws = 0;
|
||||
for (var i = 0; i < CaseCount && mismatches.Count < 5; i++)
|
||||
{
|
||||
var input = body(generator);
|
||||
var got = Outcome(() => actual(input));
|
||||
var want = generator.LastWasCutOff ? unreadable : Outcome(() => reference(input));
|
||||
if (got != want)
|
||||
mismatches.Add($"seed {seed}, case {i}: body {input}\n production: {got}\n reference: {want}");
|
||||
if (hit(want)) hits++;
|
||||
if (want == "<throws>") throws++;
|
||||
}
|
||||
Assert.True(mismatches.Count == 0, "Parser differs from the Newtonsoft reference:\n " + string.Join("\n ", mismatches));
|
||||
|
||||
// The generator must exercise the parser, not compare 20,000 identical misses.
|
||||
Assert.True(hits >= CaseCount / 50, $"only {hits} of {CaseCount} cases reach a result worth comparing");
|
||||
Assert.True(throws <= CaseCount * 7 / 10, $"{throws} of {CaseCount} cases throw; the generator barely reaches the parser");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Tests.UpstreamParserReadings;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Invariant fuzz for the Graph and Entra token-endpoint parsers (D-420), without a reference. Over bodies of
|
||||
/// every value kind, the inputs of D1 to D7 included (<see cref="UpstreamResponseCases"/>), each parser throws
|
||||
/// only what its callers expect, reads a body the same way however it is laid out, and returns no text the body
|
||||
/// did not send; <see cref="UpstreamJson.ParseObjectOrNull"/> yields an object, null or a JsonException for any
|
||||
/// text. Permanent: 500 cases per parser and check.
|
||||
/// </summary>
|
||||
public class UpstreamJsonInvariantTests
|
||||
{
|
||||
private const int CaseCount = 500;
|
||||
|
||||
/// <summary>An exception kind a parser's callers expect, named for the failure message.</summary>
|
||||
private sealed record Thrown(string Name, Func<Exception, bool> Matches);
|
||||
|
||||
// The callers catch JsonException or everything; an unreadable body or next link throws it.
|
||||
private static readonly Thrown Unreadable = new("JsonException", ex => ex is JsonException);
|
||||
|
||||
// A list entry that is no object before the match: the validators answer transient, as before the move.
|
||||
// Only the exact type counts; ObjectDisposedException derives from it and would mean a disposed document.
|
||||
private static readonly Thrown NonObjectEntry = new("InvalidOperationException", ex => ex.GetType() == typeof(InvalidOperationException));
|
||||
|
||||
/// <summary>A parser as the fuzz drives it.</summary>
|
||||
/// <param name="Seed">The generator seed; each check offsets it to see other bodies.</param>
|
||||
/// <param name="Body">The body generator for this parser.</param>
|
||||
/// <param name="Read">The canonical reading of a body.</param>
|
||||
/// <param name="Texts">Every text the result took from the body.</param>
|
||||
/// <param name="MayThrow">The exception kinds that may escape; every one of them must also occur.</param>
|
||||
private sealed record Parser(
|
||||
int Seed,
|
||||
Func<UpstreamResponseCases, string> Body,
|
||||
Func<string, string> Read,
|
||||
Func<string, IEnumerable<string?>> Texts,
|
||||
Thrown[] MayThrow);
|
||||
|
||||
private static readonly Dictionary<string, Parser> Parsers = new()
|
||||
{
|
||||
["token"] = new(4301, g => g.TokenBody(),
|
||||
b => Describe(GraphTokenService.ReadTokenResponse(b)),
|
||||
b => new[] { GraphTokenService.ReadTokenResponse(b).AccessToken },
|
||||
new[] { Unreadable }),
|
||||
["entra"] = new(4302, g => g.EntraBody(),
|
||||
b => Describe(EntraTokenRejection.Parse(400, b)),
|
||||
b => new[] { EntraTokenRejection.Parse(400, b).Description },
|
||||
Array.Empty<Thrown>()),
|
||||
["autopilot"] = new(4303, g => g.AutopilotBody(Serial),
|
||||
b => Describe(AutopilotDeviceValidator.FindRegistration(b, Serial)),
|
||||
b => AutopilotDeviceValidator.FindRegistration(b, Serial) is { } r
|
||||
? new[] { r.DeviceId, r.GroupTag, r.PurchaseOrderId, r.DisplayName }
|
||||
: Array.Empty<string?>(),
|
||||
new[] { Unreadable, NonObjectEntry }),
|
||||
["corporate"] = new(4304, g => g.CorporateBody(),
|
||||
b => Describe(CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId)),
|
||||
// An empty next url is the parser's own "no further page", not text from the body.
|
||||
b => new[] { CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId).NextUrl is { Length: > 0 } next ? next : null },
|
||||
new[] { Unreadable, NonObjectEntry }),
|
||||
["association"] = new(4305, g => g.AssociationBody(Serial),
|
||||
b => Describe(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial)),
|
||||
b =>
|
||||
{
|
||||
var r = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial);
|
||||
return new[] { r.AssociationState, r.DevicePreparationPolicyId, r.PreAssociatedByUserPrincipalName, r.AssignedToUserPrincipalName, r.ManagedDeviceId };
|
||||
},
|
||||
new[] { NonObjectEntry }),
|
||||
["cloudpc"] = new(4306, g => g.CloudPcBody(IntuneId),
|
||||
b => Describe(CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId)),
|
||||
b =>
|
||||
{
|
||||
var r = CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId);
|
||||
return new[] { r.CloudPcId, r.ManagedDeviceName, r.ServicePlanName };
|
||||
},
|
||||
new[] { NonObjectEntry }),
|
||||
["managed-device"] = new(4307, g => g.ManagedDeviceBody(IntuneId),
|
||||
b => Describe(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId)),
|
||||
b =>
|
||||
{
|
||||
var r = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId);
|
||||
return new[] { r.DeviceName, r.AzureAdDeviceId, r.ManagementState, r.SerialNumber, r.OwnerType, r.EnrollmentType };
|
||||
},
|
||||
Array.Empty<Thrown>()),
|
||||
["script-page"] = new(4308, g => g.ScriptPageBody(),
|
||||
b => Describe(ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true)),
|
||||
b =>
|
||||
{
|
||||
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true);
|
||||
return scripts.SelectMany(s => new[] { s.Id, s.DisplayName, s.FileName }).Append(next);
|
||||
},
|
||||
new[] { Unreadable }),
|
||||
["script-name"] = new(4309, g => g.ScriptNameBody(),
|
||||
b => Describe(ScriptDisplayNameResolver.ReadScriptDisplayName(b)),
|
||||
b => new[] { ScriptDisplayNameResolver.ReadScriptDisplayName(b) },
|
||||
new[] { Unreadable }),
|
||||
};
|
||||
|
||||
public static IEnumerable<object[]> ParserNames => Parsers.Keys.Select(name => new object[] { name });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParserNames))]
|
||||
public void A_parser_throws_only_what_its_callers_expect(string name)
|
||||
{
|
||||
var parser = Parsers[name];
|
||||
var cases = new UpstreamResponseCases(parser.Seed, everyValueKind: true);
|
||||
var seen = new HashSet<string>();
|
||||
for (var i = 0; i < CaseCount; i++)
|
||||
{
|
||||
var body = parser.Body(cases);
|
||||
try
|
||||
{
|
||||
parser.Read(body);
|
||||
}
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
|
||||
{
|
||||
var kind = parser.MayThrow.FirstOrDefault(t => t.Matches(ex));
|
||||
Assert.True(kind != null, $"{name}, case {i}: {ex.GetType().Name} escapes ({ex.Message}) for body {body}");
|
||||
seen.Add(kind!.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Every listed kind still occurs, so the list cannot quietly outgrow what the parser does.
|
||||
Assert.Equal(parser.MayThrow.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal), seen.OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParserNames))]
|
||||
public void Layout_never_changes_what_a_parser_reads(string name)
|
||||
{
|
||||
var parser = Parsers[name];
|
||||
var cases = new UpstreamResponseCases(parser.Seed + 100, everyValueKind: true);
|
||||
var layout = new Random(parser.Seed);
|
||||
var compared = 0;
|
||||
for (var i = 0; i < CaseCount; i++)
|
||||
{
|
||||
var body = parser.Body(cases);
|
||||
if (ReadableObject(body) is not { } root)
|
||||
continue;
|
||||
|
||||
var expected = Outcome(() => parser.Read(body));
|
||||
foreach (var spread in new[] { false, true })
|
||||
{
|
||||
var twin = Render(root, layout, spread);
|
||||
var actual = Outcome(() => parser.Read(twin));
|
||||
Assert.True(expected == actual, $"{name}, case {i}: body {body}\n laid out as {twin}\n reads {actual}\n instead of {expected}");
|
||||
}
|
||||
compared++;
|
||||
}
|
||||
|
||||
Assert.True(compared >= CaseCount / 2, $"only {compared} of {CaseCount} bodies were readable objects");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParserNames))]
|
||||
public void A_parser_returns_only_text_the_body_sent(string name)
|
||||
{
|
||||
var parser = Parsers[name];
|
||||
var cases = new UpstreamResponseCases(parser.Seed + 200, everyValueKind: true);
|
||||
var checkedTexts = 0;
|
||||
for (var i = 0; i < CaseCount; i++)
|
||||
{
|
||||
var body = parser.Body(cases);
|
||||
string?[] texts;
|
||||
try
|
||||
{
|
||||
texts = parser.Texts(body).ToArray();
|
||||
}
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
|
||||
{
|
||||
continue; // which exceptions may escape is the first check's business
|
||||
}
|
||||
|
||||
var sent = ReadableObject(body) is { } root ? SentTexts(root) : new List<string>();
|
||||
foreach (var text in texts.OfType<string>())
|
||||
{
|
||||
// Trimming and cutting keep a text inside what was sent; an empty text needs a blank one.
|
||||
var fromBody = text.Length == 0
|
||||
? sent.Any(string.IsNullOrWhiteSpace)
|
||||
: sent.Any(s => s.Contains(text, StringComparison.Ordinal));
|
||||
Assert.True(fromBody, $"{name}, case {i}: \"{text}\" is no text the body sent: {body}");
|
||||
checkedTexts++;
|
||||
}
|
||||
}
|
||||
|
||||
// The corporate page returns its next url as the only text, so the bar is low.
|
||||
Assert.True(checkedTexts >= CaseCount / 50, $"only {checkedTexts} texts came back from {CaseCount} bodies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseObjectOrNull_yields_an_object_or_null_or_throws_JsonException_for_any_text()
|
||||
{
|
||||
var edits = new Random(4310);
|
||||
int objects = 0, nulls = 0, refused = 0;
|
||||
foreach (var parser in Parsers.Values)
|
||||
{
|
||||
var cases = new UpstreamResponseCases(parser.Seed + 300, everyValueKind: true);
|
||||
for (var i = 0; i < CaseCount / 2; i++)
|
||||
{
|
||||
var body = parser.Body(cases);
|
||||
foreach (var text in new[] { body, Mutate(body, edits) })
|
||||
{
|
||||
try
|
||||
{
|
||||
if (UpstreamJson.ParseObjectOrNull(text) is { } json)
|
||||
{
|
||||
Assert.Equal(JsonValueKind.Object, json.ValueKind);
|
||||
objects++;
|
||||
}
|
||||
else
|
||||
{
|
||||
nulls++;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
refused++;
|
||||
}
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
|
||||
{
|
||||
Assert.Fail($"{ex.GetType().Name} ({ex.Message}) for text {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(objects > 0 && nulls > 0 && refused > 0, $"objects {objects}, nulls {nulls}, refused {refused}");
|
||||
}
|
||||
|
||||
private static JsonElement? ReadableObject(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
return UpstreamJson.ParseObjectOrNull(body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every string value in the body, decoded; one whose escapes are no valid UTF-16 is skipped.</summary>
|
||||
private static List<string> SentTexts(JsonElement root)
|
||||
{
|
||||
var texts = new List<string>();
|
||||
Collect(root);
|
||||
return texts;
|
||||
|
||||
void Collect(JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var member in element.EnumerateObject())
|
||||
Collect(member.Value);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
Collect(item);
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
try { texts.Add(element.GetString()!); }
|
||||
catch (InvalidOperationException) { }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The body written again in another layout: compact, or spread out with block and line comments and
|
||||
/// trailing commas. Object members change their order unless a name repeats (the last one must stay last);
|
||||
/// values keep their raw text, escapes and number form included.
|
||||
/// </summary>
|
||||
private static string Render(JsonElement root, Random rng, bool spread)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
Write(root);
|
||||
return text.ToString();
|
||||
|
||||
void Write(JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
var members = element.EnumerateObject().ToList();
|
||||
if (members.Select(m => m.Name).Distinct(StringComparer.Ordinal).Count() == members.Count)
|
||||
members = members.OrderBy(_ => rng.Next()).ToList();
|
||||
text.Append(spread ? "{ /* object */\n " : "{");
|
||||
for (var i = 0; i < members.Count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
text.Append(spread ? (rng.Next(2) == 0 ? ",// next\n " : " ,\t/* next */ ") : ",");
|
||||
text.Append(JsonSerializer.Serialize(members[i].Name)).Append(spread ? " : " : ":");
|
||||
Write(members[i].Value);
|
||||
}
|
||||
text.Append(spread && members.Count > 0 ? ",\n}" : "}");
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
var items = element.EnumerateArray().ToList();
|
||||
text.Append(spread ? "[\n " : "[");
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
text.Append(spread ? " , " : ",");
|
||||
Write(items[i]);
|
||||
}
|
||||
text.Append(spread && items.Count > 0 ? ",\n]" : "]");
|
||||
break;
|
||||
default:
|
||||
text.Append(element.GetRawText());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One random edit: a character removed, inserted or replaced, or the text cut short.</summary>
|
||||
private static string Mutate(string text, Random rng)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
return "{";
|
||||
const string alphabet = "{}[]\",:/*\\ \n0123456789-+.eEtrufalsné";
|
||||
var at = rng.Next(text.Length);
|
||||
var c = alphabet[rng.Next(alphabet.Length)].ToString();
|
||||
return rng.Next(4) switch
|
||||
{
|
||||
0 => text.Remove(at, 1),
|
||||
1 => text.Insert(at, c),
|
||||
2 => text.Substring(0, at),
|
||||
_ => text.Remove(at, 1).Insert(at, c),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The contract of <see cref="UpstreamJson"/> (D-420), the reader under every Graph and Entra token-endpoint
|
||||
/// parser: one test group per member, then the culture matrix. The parser-level contract is in
|
||||
/// <see cref="GraphResponseParsingTests"/>.
|
||||
/// </summary>
|
||||
public class UpstreamJsonTests
|
||||
{
|
||||
private static JsonElement Obj(string json) => UpstreamJson.ParseObjectOrNull(json)!.Value;
|
||||
|
||||
// ── ParseObjectOrNull ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\r\n\t")]
|
||||
[InlineData("null")]
|
||||
[InlineData(" null ")]
|
||||
[InlineData("/*c*/ null")]
|
||||
public void ParseObjectOrNull_reads_a_blank_body_and_a_null_root_as_no_object(string? body)
|
||||
=> Assert.Null(UpstreamJson.ParseObjectOrNull(body));
|
||||
|
||||
[Theory]
|
||||
[InlineData("{}")]
|
||||
[InlineData(" { \"a\" : 1 } ")]
|
||||
[InlineData("{\"a\":1,}")]
|
||||
[InlineData("{/*c*/\"a\":[1,2,],// line\n}")]
|
||||
[InlineData("// leading\n{\"a\":1}")]
|
||||
public void ParseObjectOrNull_reads_one_object_with_comments_and_trailing_commas(string body)
|
||||
=> Assert.Equal(JsonValueKind.Object, UpstreamJson.ParseObjectOrNull(body)!.Value.ValueKind);
|
||||
|
||||
[Fact]
|
||||
public void ParseObjectOrNull_returns_an_element_that_outlives_the_parse()
|
||||
{
|
||||
// The document is disposed inside the call; only a cloned root stays readable.
|
||||
var json = Obj("""{"a":"b","list":[{"c":"d"}]}""");
|
||||
GC.Collect();
|
||||
Assert.Equal("b", json.GetProperty("a").GetString());
|
||||
Assert.Equal("d", json.GetProperty("list")[0].GetProperty("c").GetString());
|
||||
}
|
||||
|
||||
// Everything that is not exactly one object throws JsonException, the type the callers catch. The syntax
|
||||
// only Newtonsoft tolerated (single quotes, bare names, NaN, hex, undefined, raw line breaks in strings)
|
||||
// is malformed too; Graph and Entra never send it.
|
||||
[Theory]
|
||||
[InlineData("[]")]
|
||||
[InlineData("[{}]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("\"text\"")]
|
||||
[InlineData("true")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("<html>busy</html>")]
|
||||
[InlineData("{")]
|
||||
[InlineData("{\"a\":}")]
|
||||
[InlineData("{} x")]
|
||||
[InlineData("{}{}")]
|
||||
[InlineData("{} /* unterminated")]
|
||||
[InlineData("{'a':'b'}")]
|
||||
[InlineData("{a:1}")]
|
||||
[InlineData("{\"a\":NaN}")]
|
||||
[InlineData("{\"a\":0x1F}")]
|
||||
[InlineData("{\"a\":undefined}")]
|
||||
[InlineData("{\"a\":\"line\nbreak\"}")]
|
||||
[InlineData("{\"a\":1,,}")]
|
||||
public void ParseObjectOrNull_throws_JsonException_for_anything_but_one_object(string body)
|
||||
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(body));
|
||||
|
||||
[Fact]
|
||||
public void ParseObjectOrNull_throws_JsonException_for_nesting_beyond_the_depth_limit()
|
||||
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(
|
||||
"{\"a\":" + new string('[', 100) + new string(']', 100) + "}"));
|
||||
|
||||
// ── GetString ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"a":"value"}""", "value")]
|
||||
[InlineData("""{"a":""}""", "")]
|
||||
[InlineData("""{"a":" padded "}""", " padded ")]
|
||||
[InlineData("""{"a":"quote\" back\\ slash\n tab\t é 😀"}""", "quote\" back\\ slash\n tab\t é 😀")]
|
||||
[InlineData("""{"a":"2026-01-01T00:00:00Z"}""", "2026-01-01T00:00:00Z")]
|
||||
[InlineData("""{"a":"/Date(1713096021000)/"}""", "/Date(1713096021000)/")]
|
||||
[InlineData("""{"a":"42"}""", "42")]
|
||||
[InlineData("""{"a":"first","a":"last"}""", "last")]
|
||||
public void GetString_reads_text_as_sent(string json, string expected)
|
||||
=> Assert.Equal(expected, UpstreamJson.GetString(Obj(json), "a"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{}""")]
|
||||
[InlineData("""{"a":null}""")]
|
||||
[InlineData("""{"a":42}""")]
|
||||
[InlineData("""{"a":4.2}""")]
|
||||
[InlineData("""{"a":true}""")]
|
||||
[InlineData("""{"a":false}""")]
|
||||
[InlineData("""{"a":{}}""")]
|
||||
[InlineData("""{"a":[]}""")]
|
||||
[InlineData("""{"a":["x"]}""")]
|
||||
[InlineData("""{"A":"other case"}""")]
|
||||
[InlineData("""{"a ":"other name"}""")]
|
||||
public void GetString_reads_anything_but_text_under_the_exact_name_as_absent(string json)
|
||||
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("null")]
|
||||
[InlineData("42")]
|
||||
[InlineData("\"text\"")]
|
||||
[InlineData("[]")]
|
||||
public void GetString_on_a_list_entry_that_is_no_object_throws_InvalidOperationException(string entry)
|
||||
{
|
||||
// The validators rely on this: a non-object entry before the match keeps ending the lookup as transient.
|
||||
var item = Obj($$"""{"value":[{{entry}}]}""").GetProperty("value")[0];
|
||||
Assert.Throws<InvalidOperationException>(() => UpstreamJson.GetString(item, "a"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"a":"x\ud800y"}""")]
|
||||
[InlineData("""{"a":"\udc00"}""")]
|
||||
[InlineData("""{"a":"\ud800A"}""")]
|
||||
public void GetString_reads_text_with_a_broken_surrogate_escape_as_absent(string json)
|
||||
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
|
||||
|
||||
// ── Items ───────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Items_yields_every_array_entry_in_order_whatever_its_kind()
|
||||
=> Assert.Equal(
|
||||
new[] { JsonValueKind.Object, JsonValueKind.Null, JsonValueKind.Number, JsonValueKind.String, JsonValueKind.Array },
|
||||
UpstreamJson.Items(Obj("""{"value":[{},null,1,"x",[]]}"""), "value").Select(i => i.ValueKind));
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{}""")]
|
||||
[InlineData("""{"value":null}""")]
|
||||
[InlineData("""{"value":{}}""")]
|
||||
[InlineData("""{"value":"oops"}""")]
|
||||
[InlineData("""{"value":42}""")]
|
||||
[InlineData("""{"value":[]}""")]
|
||||
[InlineData("""{"Value":[1]}""")]
|
||||
public void Items_is_empty_unless_the_property_is_a_non_empty_array(string json)
|
||||
=> Assert.Empty(UpstreamJson.Items(Obj(json), "value"));
|
||||
|
||||
// ── GetNextLink ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{}""", null)]
|
||||
[InlineData("""{"@odata.nextLink":null}""", null)]
|
||||
[InlineData("""{"@odata.nextLink":""}""", "")]
|
||||
[InlineData("""{"@odata.nextLink":"https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb"}""", "https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb")]
|
||||
[InlineData("""{"@odata.nextlink":"https://graph.microsoft.com/beta/other-case"}""", null)]
|
||||
public void GetNextLink_reads_the_link_or_its_absence(string json, string? expected)
|
||||
=> Assert.Equal(expected, UpstreamJson.GetNextLink(Obj(json)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("5")]
|
||||
[InlineData("true")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("[\"https://graph.microsoft.com/beta/x\"]")]
|
||||
[InlineData("\"https://graph.microsoft.com/beta/x\\ud800\"")]
|
||||
public void GetNextLink_throws_for_a_link_that_is_not_text(string link)
|
||||
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.GetNextLink(Obj($$"""{"@odata.nextLink":{{link}}}""")));
|
||||
|
||||
// ── GetInt64 ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("3599", 3599L)]
|
||||
[InlineData("0", 0L)]
|
||||
[InlineData("-5", -5L)]
|
||||
[InlineData("9223372036854775807", long.MaxValue)]
|
||||
[InlineData("-9223372036854775808", long.MinValue)]
|
||||
[InlineData("\"3599\"", 3599L)]
|
||||
[InlineData("\" 3599 \"", 3599L)]
|
||||
[InlineData("\"+3599\"", 3599L)]
|
||||
[InlineData("\"-1\"", -1L)]
|
||||
public void GetInt64_reads_a_whole_number_or_its_text(string value, long expected)
|
||||
=> Assert.Equal(expected, UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("3599.5")]
|
||||
[InlineData("3599.0")]
|
||||
[InlineData("7e2")]
|
||||
[InlineData("9223372036854775808")]
|
||||
[InlineData("99999999999999999999")]
|
||||
[InlineData("\"3599.0\"")]
|
||||
[InlineData("\"3,599\"")]
|
||||
[InlineData("\"3.599\"")]
|
||||
[InlineData("\"0x10\"")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\"abc\"")]
|
||||
[InlineData("\"\u0661\u0662\u0663\"")]
|
||||
[InlineData("\"\\ud800\"")]
|
||||
[InlineData("null")]
|
||||
[InlineData("true")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
public void GetInt64_reads_anything_but_a_whole_number_as_absent(string value)
|
||||
=> Assert.Null(UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
|
||||
|
||||
[Fact]
|
||||
public void GetInt64_reads_a_missing_property_as_absent()
|
||||
=> Assert.Null(UpstreamJson.GetInt64(Obj("{}"), "n"));
|
||||
|
||||
// ── GetBoolean ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("false", false)]
|
||||
[InlineData("null", null)]
|
||||
[InlineData("\"true\"", null)]
|
||||
[InlineData("\"false\"", null)]
|
||||
[InlineData("1", null)]
|
||||
[InlineData("0", null)]
|
||||
[InlineData("{}", null)]
|
||||
public void GetBoolean_reads_only_true_and_false(string value, bool? expected)
|
||||
=> Assert.Equal(expected, UpstreamJson.GetBoolean(Obj($$"""{"b":{{value}}}"""), "b"));
|
||||
|
||||
[Fact]
|
||||
public void GetBoolean_reads_a_missing_property_as_absent()
|
||||
=> Assert.Null(UpstreamJson.GetBoolean(Obj("{}"), "b"));
|
||||
|
||||
// ── GetUtcDateTime ──────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-05-08T12:16:49Z", 0L)]
|
||||
[InlineData("2026-05-08T12:16:49.1234567Z", 1234567L)]
|
||||
[InlineData("2026-05-08T12:16:49.5Z", 5000000L)]
|
||||
[InlineData("2026-05-08T14:16:49+02:00", 0L)]
|
||||
[InlineData("2026-05-08T07:16:49.1234567-05:00", 1234567L)]
|
||||
[InlineData("2026-05-08T12:16:49", 0L)]
|
||||
public void GetUtcDateTime_reads_the_instant_in_utc_with_full_precision(string value, long fractionTicks)
|
||||
{
|
||||
var date = UpstreamJson.GetUtcDateTime(Obj($$"""{"d":"{{value}}"}"""), "d");
|
||||
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(fractionTicks), date);
|
||||
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUtcDateTime_reads_a_date_without_time_as_its_utc_midnight()
|
||||
=> Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(Obj("""{"d":"2026-05-08"}"""), "d"));
|
||||
|
||||
// Graph sends 0001-01-01 for a date it does not have: absence, not a date two thousand years ago.
|
||||
[Theory]
|
||||
[InlineData("\"0001-01-01T00:00:00Z\"")]
|
||||
[InlineData("\"0001-01-01T00:00:00\"")]
|
||||
[InlineData("\"0001-01-01\"")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\" \"")]
|
||||
[InlineData("\"not-a-date\"")]
|
||||
[InlineData("\"2026-13-01T00:00:00Z\"")]
|
||||
[InlineData("\"/Date(1713096021000)/\"")]
|
||||
[InlineData("null")]
|
||||
[InlineData("20260508")]
|
||||
[InlineData("true")]
|
||||
[InlineData("{}")]
|
||||
public void GetUtcDateTime_reads_an_unset_or_unusable_value_as_absent(string value)
|
||||
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj($$"""{"d":{{value}}}"""), "d"));
|
||||
|
||||
[Fact]
|
||||
public void GetUtcDateTime_reads_a_missing_property_as_absent()
|
||||
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj("{}"), "d"));
|
||||
|
||||
// ── Culture matrix ──────────────────────────────────────────────────────────
|
||||
|
||||
// No member may depend on the host culture: day-first, Thai Buddhist calendar and the en-US default all
|
||||
// read the same body the same way.
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("en-GB")]
|
||||
[InlineData("th-TH")]
|
||||
public void Every_member_reads_the_same_in_any_culture(string culture)
|
||||
{
|
||||
var saved = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
||||
var json = Obj("""{"d":"2026-05-08T12:16:49.1234567Z","day":"2026-05-08","n":"3599","t":"2026-05-08T12:16:49Z","f":"1.5"}""");
|
||||
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), UpstreamJson.GetUtcDateTime(json, "d"));
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(json, "day"));
|
||||
Assert.Equal(3599L, UpstreamJson.GetInt64(json, "n"));
|
||||
Assert.Equal("2026-05-08T12:16:49Z", UpstreamJson.GetString(json, "t"));
|
||||
Assert.Null(UpstreamJson.GetInt64(json, "f"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One canonical text per result of the Graph and Entra token-endpoint parsers (D-420), so two readings of a
|
||||
/// body compare as strings: the differential test compares production with the Newtonsoft reference, the
|
||||
/// invariant fuzz compares a body with its reformatted twin.
|
||||
/// </summary>
|
||||
internal static class UpstreamParserReadings
|
||||
{
|
||||
internal const string Serial = "PF3XKQ7";
|
||||
internal const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
|
||||
|
||||
internal static readonly string CorporateId =
|
||||
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
|
||||
|
||||
/// <summary>The reading, or "<throws>" when the parser throws.</summary>
|
||||
internal static string Outcome(Func<string> run)
|
||||
{
|
||||
try { return run(); }
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) { return "<throws>"; }
|
||||
}
|
||||
|
||||
private static string Text(string? value) => value is null ? "<null>" : "\"" + value + "\"";
|
||||
|
||||
private static string Date(DateTime? value) => value is { } d ? d.ToString("O", CultureInfo.InvariantCulture) + "/" + d.Kind : "<null>";
|
||||
|
||||
// Every consumer reads the token with IsNullOrEmpty, so an empty and an absent token describe alike.
|
||||
internal static string Describe((string? AccessToken, long? ExpiresInSeconds) r) =>
|
||||
$"token={(string.IsNullOrEmpty(r.AccessToken) ? "<none>" : Text(r.AccessToken))} expires={r.ExpiresInSeconds?.ToString(CultureInfo.InvariantCulture) ?? "<null>"}";
|
||||
|
||||
internal static string Describe(EntraTokenRejection r) => $"status={r.StatusCode} code={Text(r.ErrorCode)} description={Text(r.Description)}";
|
||||
|
||||
internal static string Describe(AutopilotMonitor.Shared.Models.AutopilotRegistration? r) => r is null
|
||||
? "<not registered>"
|
||||
: $"id={Text(r.DeviceId)} tag={Text(r.GroupTag)} po={Text(r.PurchaseOrderId)} name={Text(r.DisplayName)} user={r.UserAssigned}";
|
||||
|
||||
internal static string Describe((bool Found, string NextUrl) r) => $"found={r.Found} next={Text(r.NextUrl)}";
|
||||
|
||||
internal static string Describe(DeviceAssociationResult r) =>
|
||||
$"valid={r.IsValid} transient={r.IsTransient} serial={Text(r.SerialNumber)} state={Text(r.AssociationState)} policy={Text(r.DevicePreparationPolicyId)} " +
|
||||
$"preBy={Text(r.PreAssociatedByUserPrincipalName)} assigned={Text(r.AssignedToUserPrincipalName)} pre={Date(r.PreAssociationDateTime)} " +
|
||||
$"at={Date(r.AssociationDateTime)} device={Text(r.ManagedDeviceId)} error={Text(r.ErrorMessage)}";
|
||||
|
||||
internal static string Describe(CloudPcValidationResult r) =>
|
||||
$"valid={r.IsValid} transient={r.IsTransient} device={Text(r.IntuneDeviceId)} cpc={Text(r.CloudPcId)} name={Text(r.ManagedDeviceName)} " +
|
||||
$"plan={Text(r.ServicePlanName)} error={Text(r.ErrorMessage)}";
|
||||
|
||||
internal static string Describe(IntuneDeviceBindingResult r) =>
|
||||
$"outcome={r.Outcome} device={Text(r.IntuneDeviceId)} name={Text(r.DeviceName)} aad={Text(r.AzureAdDeviceId)} state={Text(r.ManagementState)} " +
|
||||
$"serial={Text(r.SerialNumber)} owner={Text(r.OwnerType)} enrollment={Text(r.EnrollmentType)} registered={r.AzureAdRegistered?.ToString() ?? "<null>"} " +
|
||||
$"enrolled={(r.EnrolledDateTime is { } e ? e.ToString("O", CultureInfo.InvariantCulture) : "<null>")} error={Text(r.ErrorMessage)}";
|
||||
|
||||
// A null or empty next link both end the full pull, so they describe alike.
|
||||
internal static string Describe((List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) r) =>
|
||||
string.Join(";", r.Scripts.Select(s => $"{Text(s.Id)}={Text(s.DisplayName)}/{Text(s.FileName)}")) + $" next={Text(string.IsNullOrEmpty(r.NextUrl) ? null : r.NextUrl)}";
|
||||
|
||||
internal static string Describe(string? scriptName) => scriptName ?? "<null>";
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic Graph and Entra token-endpoint response bodies for the parser tests of D-420. They vary root
|
||||
/// kind, empty, broken and cut-off bodies, trailing content, comments, trailing commas, whitespace, property
|
||||
/// order, unknown and duplicate properties, mixed list entries, text with escapes and Unicode, paging links,
|
||||
/// lifetimes as number or numeric text, and error-code variants.
|
||||
/// By default they leave out the inputs of the deliberate differences D1 to D6, so the old and the new parser
|
||||
/// must agree on them. With <c>everyValueKind</c> they add those inputs too: JSON null, numbers, booleans,
|
||||
/// objects and arrays in text fields, date-shaped text, dates with fractions and offsets, escapes that are no
|
||||
/// valid UTF-16, unusable lifetimes and error codes.
|
||||
/// </summary>
|
||||
internal sealed class UpstreamResponseCases
|
||||
{
|
||||
// The same few literals come back thousands of times; serializing each once keeps the fuzz fast. Declared
|
||||
// first: the pools below call Str while the type initializes.
|
||||
private static readonly ConcurrentDictionary<string, string> Serialized = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly Random _rng;
|
||||
private readonly bool _everyValueKind;
|
||||
|
||||
public UpstreamResponseCases(int seed, bool everyValueKind = false)
|
||||
{
|
||||
_rng = new Random(seed);
|
||||
_everyValueKind = everyValueKind;
|
||||
}
|
||||
|
||||
/// <summary>Whether the last body was the object cut off halfway (D7).</summary>
|
||||
public bool LastWasCutOff { get; private set; }
|
||||
|
||||
// Plain text that is never a date in either library and never JSON null.
|
||||
private static readonly string[] PlainTexts =
|
||||
{
|
||||
Str("value"), Str(" padded "), Str("UPPER lower 123"), "\"Ünïcødé ☃ ε\"", Str("quote\" back\\ slash\nnew line"),
|
||||
Str(new string('x', 300)), Str(""), Str(" "), Str("a/b?c=d&e"), Str("é\u0001\u007f"), Str("2026"), Str("12:16"),
|
||||
};
|
||||
|
||||
// What D1, D3 and D4 read differently: JSON null, other kinds, date-shaped text, an escape that is no valid UTF-16.
|
||||
private static readonly string[] DeliberateTexts =
|
||||
{
|
||||
"null", "42", "-1.5", "true", "{}", "[]", "[\"x\"]", Str("2026-01-01T00:00:00Z"), Str("/Date(1713096021000)/"), "\"x\\ud800y\"",
|
||||
};
|
||||
|
||||
// A whole-second UTC date, a date without time, or a value both read as no date.
|
||||
private static readonly string[] Dates =
|
||||
{
|
||||
Str("2026-05-08T12:16:49Z"), Str("2025-12-31T23:59:59Z"), Str("2026-05-08"), Str("0001-01-01T00:00:00Z"),
|
||||
Str(""), Str("not-a-date"), "null",
|
||||
};
|
||||
|
||||
// What D2 reads differently (fraction, offset, no offset) and dates that are no text.
|
||||
private static readonly string[] DeliberateDates =
|
||||
{
|
||||
Str("2026-05-08T12:16:49.1234567Z"), Str("2026-05-08T14:16:49+02:00"), Str("2026-05-08T12:16:49"), "20260508", "true",
|
||||
};
|
||||
|
||||
private T Pick<T>(params T[] items) => items[_rng.Next(items.Length)];
|
||||
|
||||
/// <summary>One of <paramref name="values"/>; with every value kind, one of those or of <paramref name="deliberate"/>.</summary>
|
||||
private string Value(string[] values, params string[] deliberate)
|
||||
{
|
||||
var i = _rng.Next(values.Length + (_everyValueKind ? deliberate.Length : 0));
|
||||
return i < values.Length ? values[i] : deliberate[i - values.Length];
|
||||
}
|
||||
|
||||
private bool Chance(int percent) => _rng.Next(100) < percent;
|
||||
|
||||
private static string Str(string s) => Serialized.GetOrAdd(s, value => System.Text.Json.JsonSerializer.Serialize(value));
|
||||
|
||||
private string Ws() => Pick("", "", "", " ", "\n", "\r\n ", "\t");
|
||||
|
||||
private string Text() => Value(PlainTexts, DeliberateTexts);
|
||||
|
||||
private string Url() => Pick(
|
||||
Str("https://graph.microsoft.com/beta/next?$skiptoken=abc"), Str("https://graph.microsoft.com/v1.0/x?$top=5"));
|
||||
|
||||
private string DateValue() => Value(Dates, DeliberateDates);
|
||||
|
||||
/// <summary>A property: (name, json) or (name, null) when the property is left out.</summary>
|
||||
private (string Name, string? Json) Prop(string name, string json, int missingPercent = 15)
|
||||
=> (name, Chance(missingPercent) ? null : json);
|
||||
|
||||
private string Obj(params (string Name, string? Json)[] props)
|
||||
{
|
||||
var parts = props.Where(p => p.Json != null).Select(p => $"{Str(p.Name)}:{Ws()}{p.Json}").ToList();
|
||||
if (Chance(10))
|
||||
parts.Insert(_rng.Next(parts.Count + 1), $"{Str("unknownProperty")}:{Pick("1", "\"x\"", "{}", "[]", "null", "true")}");
|
||||
if (Chance(5) && parts.Count > 0)
|
||||
{
|
||||
// Duplicate key with the same value: whichever occurrence wins, the meaning stays.
|
||||
parts.Insert(0, parts[_rng.Next(parts.Count)]);
|
||||
}
|
||||
if (Chance(30))
|
||||
parts = parts.OrderBy(_ => _rng.Next()).ToList();
|
||||
var separator = Pick(",", ",", ", ", ",\n ", ", /*c*/ ", ",// line\n");
|
||||
var trailing = parts.Count > 0 && Chance(5) ? "," : "";
|
||||
return "{" + Ws() + string.Join(separator, parts) + trailing + Ws() + "}";
|
||||
}
|
||||
|
||||
private string ValueList(Func<string> item)
|
||||
{
|
||||
switch (_rng.Next(100))
|
||||
{
|
||||
case < 5: return "null";
|
||||
case < 9: return Pick("\"oops\"", "{}", "42");
|
||||
case < 14: return "[]";
|
||||
}
|
||||
var entries = new List<string>();
|
||||
var count = _rng.Next(1, 6);
|
||||
for (var i = 0; i < count; i++)
|
||||
entries.Add(_rng.Next(100) switch { < 85 => item(), < 90 => "null", < 94 => Pick("1", "\"x\"", "true"), < 97 => "[]", _ => "{}" });
|
||||
return "[" + Ws() + string.Join("," + Ws(), entries) + Ws() + "]";
|
||||
}
|
||||
|
||||
/// <summary>The body around an object: mostly the object itself, else a root or shape both refuse.</summary>
|
||||
private string Root(string objectJson)
|
||||
{
|
||||
var pick = _rng.Next(100);
|
||||
LastWasCutOff = pick is >= 92 and < 95;
|
||||
return pick switch
|
||||
{
|
||||
< 82 => Ws() + objectJson + Ws(),
|
||||
< 84 => "",
|
||||
< 86 => " ",
|
||||
< 88 => "null",
|
||||
< 90 => "[" + objectJson + "]",
|
||||
< 92 => Pick("42", "\"text\"", "true"),
|
||||
< 95 => objectJson.Substring(0, Math.Max(1, objectJson.Length / 2)),
|
||||
< 98 => objectJson + Pick(" x", " {}", " 1"),
|
||||
_ => Pick("<html>busy</html>", "not json"),
|
||||
};
|
||||
}
|
||||
|
||||
private string Matching(string target) => Value(
|
||||
new[] { Str(target), Str(target.ToLowerInvariant()), Str(" " + target + " "), Str(target + "X"), Str("OTHER"), Str(""), "12345", "true" },
|
||||
"null", "{}", "[" + Str(target) + "]", "\"" + target + "\\ud800\"");
|
||||
|
||||
public string TokenBody() => Root(Obj(
|
||||
Prop("access_token", Value(new[] { Str("eyJ0eXAi.token"), Str("t"), Str(""), "null" }, "42", "{}", "\"t\\ud800\"")),
|
||||
Prop("expires_in", Value(
|
||||
new[] { "3599", "0", "-5", "7200", "9223372036854775807", "\"3599\"", "\" 3599 \"", "\"+3599\"", "\"-1\"", "null" },
|
||||
"\"abc\"", "\"\"", "3599.5", "7e2", "true", "99999999999999999999", "{}", "[]", "\"\\ud800\"")),
|
||||
Prop("token_type", Str("Bearer"))));
|
||||
|
||||
public string EntraBody() => Root(Obj(
|
||||
Prop("error", Pick(Str("invalid_client"), Str("unauthorized_client"))),
|
||||
Prop("error_description", Value(
|
||||
new[]
|
||||
{
|
||||
Str("AADSTS7000229: The client application is missing a service principal.\r\nTrace ID: x"),
|
||||
Str("AADSTS53003: Access has been blocked by Conditional Access policies."), Str("no code here"), Str("AADSTS: no digits"),
|
||||
Str(new string('d', 400)), Str(" "), "null", "42", "true", "{}", "[]",
|
||||
},
|
||||
Str("2026-11-01T08:00:00Z"), "\"AADSTS53003: broken \\ud800\"")),
|
||||
Prop("error_codes", Value(
|
||||
new[] { "[7000229]", "[53003,7000229]", "[\"7000229\",53003]", "[7000229.5]", "[7e6]", "[-1]", "[]", "7000229", "null", "[" + _rng.Next(1, 99_999_999) + "]" },
|
||||
"[99999999999999999999,53003]", "[1e3]")),
|
||||
Prop("timestamp", Str("2026-11-05 08:00:00Z"))));
|
||||
|
||||
public string AutopilotBody(string serial) => Root(Obj(
|
||||
Prop("@odata.context", Str("https://graph.microsoft.com/v1.0/$metadata#deviceManagement/windowsAutopilotDeviceIdentities")),
|
||||
Prop("value", ValueList(() => Obj(
|
||||
Prop("id", Pick(Text(), Str("ap-1"), "null", "42")),
|
||||
Prop("serialNumber", Matching(serial), 10),
|
||||
Prop("groupTag", Pick(Text(), "null", "true")),
|
||||
Prop("purchaseOrderIdentifier", Pick(Text(), "null", "{}")),
|
||||
Prop("displayName", Pick(Text(), "null", "[]")),
|
||||
Prop("userPrincipalName", Pick(Text(), "null", Str("u@contoso.com"))))), 5)));
|
||||
|
||||
public string CorporateBody()
|
||||
{
|
||||
var identifier = Value(
|
||||
new[]
|
||||
{
|
||||
Str("Microsoft Corporation,Virtual Machine,1234-5678"), Str("MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"),
|
||||
Str("microsoft corporation, virtual machine, 1234 5678"), Str("Other,Model,1"), Str(""), "null", "42",
|
||||
},
|
||||
"[" + Str("Microsoft Corporation,Virtual Machine,1234-5678") + "]", "true", "\"Microsoft Corporation,Virtual Machine,1234-5678\\ud800\"");
|
||||
return Root(Obj(
|
||||
Prop("value", ValueList(() => Obj(
|
||||
Prop("importedDeviceIdentityType", Value(new[] { Str("manufacturerModelSerial"), Str("MANUFACTURERMODELSERIAL"), Str("serialNumber"), "null" }, "42")),
|
||||
Prop("importedDeviceIdentifier", identifier))), 5),
|
||||
Prop("@odata.nextLink", Value(new[] { Url(), Str(""), "null" }, "5", "true", "{}", "\"https://graph.microsoft.com/beta/next\\ud800\""), 50)));
|
||||
}
|
||||
|
||||
public string AssociationBody(string serial) => Root(Obj(
|
||||
Prop("value", ValueList(() => Obj(
|
||||
Prop("serialNumber", Matching(serial), 10),
|
||||
Prop("associationState", Pick(Str("associated"), Str("preAssociated"), Text())),
|
||||
Prop("devicePreparationPolicyId", Text()),
|
||||
Prop("preassociatedByUserPrincipalName", Text()),
|
||||
Prop("assignedToUserPrincipalName", Text()),
|
||||
Prop("preassociationDateTime", DateValue()),
|
||||
Prop("associationDateTime", DateValue()),
|
||||
Prop("managedDeviceId", Text()))), 5)));
|
||||
|
||||
public string CloudPcBody(string intuneId) => Root(Obj(
|
||||
Prop("value", ValueList(() => Obj(
|
||||
Prop("id", Text()),
|
||||
Prop("managedDeviceId", Matching(intuneId), 10),
|
||||
Prop("managedDeviceName", Text()),
|
||||
Prop("servicePlanName", Text()))), 5)));
|
||||
|
||||
public string ManagedDeviceBody(string intuneId) => Root(Obj(
|
||||
Prop("id", Matching(intuneId), 10),
|
||||
Prop("deviceName", Text()),
|
||||
Prop("azureADDeviceId", Text()),
|
||||
Prop("managementState", Pick(Str("managed"), Str("retirePending"), Str("WIPEISSUED"), Str("deletePending"), Text())),
|
||||
Prop("serialNumber", Pick(Text(), "null")),
|
||||
Prop("managedDeviceOwnerType", Pick(Str("company"), Text(), "null")),
|
||||
Prop("deviceEnrollmentType", Pick(Str("windowsAzureADJoin"), Text(), "null")),
|
||||
Prop("azureADRegistered", Value(new[] { "true", "false", "null", "\"true\"", "1" }, "\"false\"", "0", "{}")),
|
||||
Prop("enrolledDateTime", DateValue())));
|
||||
|
||||
public string ScriptPageBody() => Root(Obj(
|
||||
Prop("value", ValueList(() => Obj(
|
||||
Prop("id", Value(new[] { Str("s1"), Str("4f9e2c1a-0000-0000-0000-000000000001"), Str(""), "null" }, "42", "{}", "\"s1\\ud800\""), 10),
|
||||
Prop("displayName", Text()),
|
||||
Prop("fileName", Text()))), 5),
|
||||
Prop("@odata.nextLink", Value(new[] { Url(), Str(""), "null" }, "5", "{}", "\"https://graph.microsoft.com/beta/p2\\ud800\""), 50)));
|
||||
|
||||
public string ScriptNameBody() => Root(Obj(
|
||||
Prop("id", Str("s1")),
|
||||
Prop("displayName", Text()),
|
||||
Prop("fileName", Text())));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// What runs while a Global Admin views a tenant as its member (read-only "view as"). The class follows the
|
||||
/// method — GET reads, everything else writes and is refused — so a new route fails closed. Every exception is
|
||||
/// listed here with its reason: a POST that only reads, or a GET that starts an action. A new exception needs a
|
||||
/// deliberate line, and a GET that writes must either be listed or skip its write under view-as in the handler
|
||||
/// (RecomputeTriggerGate, the consent-status auto-flip).
|
||||
/// </summary>
|
||||
public class ViewAsAccessCatalogTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> Exceptions = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["PUT auth/me/whats-new-seen"] = "the caller's own What's-new marker under the JWT tenant; opening What's new sends it",
|
||||
["POST realtime/negotiate"] = "a live-update subscription, nothing stored",
|
||||
["POST realtime/groups/join"] = "a live-update subscription, nothing stored",
|
||||
["POST realtime/groups/leave"] = "a live-update subscription, nothing stored",
|
||||
["POST diagnostics/download-ticket"] = "a stateless HMAC ticket for a read",
|
||||
["POST rules/analyze/dryrun"] = "evaluates a draft rule against a session and persists nothing",
|
||||
["POST rules/gather/test-pattern"] = "a regex against pasted lines, no tenant data",
|
||||
["POST tenants/{tenantId}/scripts/display-names"] = "a read sent as POST for its body size",
|
||||
["POST tenants/{tenantId}/graph-permissions/refresh"] = "clears the in-memory Graph cache only",
|
||||
["GET config/{tenantId}/autopilot-device-validation/consent-url"] = "starts the admin-consent flow (ops events with the caller's UPN)",
|
||||
};
|
||||
|
||||
private static ViewAsAccess MethodDefault(string method) => method == "GET" ? ViewAsAccess.Read : ViewAsAccess.Write;
|
||||
|
||||
[Fact]
|
||||
public void Every_class_that_differs_from_the_method_default_is_listed_with_a_reason()
|
||||
{
|
||||
var actual = EndpointAccessPolicyCatalog.Entries
|
||||
.Where(e => e.ViewAsAccess != MethodDefault(e.HttpMethod))
|
||||
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
|
||||
.OrderBy(k => k, StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal(Exceptions.Keys.OrderBy(k => k, StringComparer.Ordinal), actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", ViewAsAccess.Read)]
|
||||
[InlineData("post", ViewAsAccess.Write)]
|
||||
[InlineData("PUT", ViewAsAccess.Write)]
|
||||
[InlineData("PATCH", ViewAsAccess.Write)]
|
||||
[InlineData("DELETE", ViewAsAccess.Write)]
|
||||
public void An_unclassified_route_follows_its_method(string method, ViewAsAccess expected)
|
||||
=> Assert.Equal(expected, new EndpointPolicyEntry(method, "some/route", EndpointPolicy.MemberRead).ViewAsAccess);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The pieces of "view as tenant" outside the policy decision: the header parse, the data scope every handler
|
||||
/// reads (<see cref="TenantHelper"/>), the recompute gate that guards two GET actions, and the places where the
|
||||
/// real caller — not the viewed tenant — must count (presence, the request row, every other telemetry item).
|
||||
/// </summary>
|
||||
public class ViewAsTests
|
||||
{
|
||||
private const string Home = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Viewed = "22222222-2222-2222-2222-222222222222";
|
||||
private const string GaUpn = "ga@vendor.example";
|
||||
|
||||
private static RequestContext ViewingContext(string role = Constants.TenantRoles.Admin) => new()
|
||||
{
|
||||
TenantId = Viewed,
|
||||
TargetTenantId = Viewed,
|
||||
UserPrincipalName = GaUpn,
|
||||
UserRole = role,
|
||||
IsTenantAdmin = role == Constants.TenantRoles.Admin,
|
||||
ViewAs = new ViewAsContext(Home, role),
|
||||
};
|
||||
|
||||
// ── header parse ─────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void No_tenant_header_is_an_ordinary_request(string? tenantHeader)
|
||||
=> Assert.Null(ViewAsTarget.FromHeaders(tenantHeader, "Viewer"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-guid", null)]
|
||||
[InlineData("00000000-0000-0000-0000-000000000000", null)]
|
||||
[InlineData(Viewed, "GlobalAdmin")]
|
||||
[InlineData(Viewed, "Member")]
|
||||
public void Unusable_headers_are_invalid(string tenantHeader, string? roleHeader)
|
||||
=> Assert.Same(ViewAsTarget.Invalid, ViewAsTarget.FromHeaders(tenantHeader, roleHeader));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, Constants.TenantRoles.Admin)]
|
||||
[InlineData("viewer", Constants.TenantRoles.Viewer)]
|
||||
[InlineData(" OPERATOR ", Constants.TenantRoles.Operator)]
|
||||
public void A_tenant_id_is_normalized_and_the_role_defaults_to_admin(string? roleHeader, string expectedRole)
|
||||
{
|
||||
var request = ViewAsTarget.FromHeaders(" 22222222-2222-2222-2222-22222222222A ", roleHeader);
|
||||
|
||||
Assert.Equal(new ViewAsTarget("22222222-2222-2222-2222-22222222222a", expectedRole), request);
|
||||
Assert.True(request!.IsValid);
|
||||
}
|
||||
|
||||
// ── data scope and identity ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TenantHelper_answers_the_viewed_tenant_under_view_as_and_the_token_tenant_otherwise()
|
||||
{
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", Home), new Claim("upn", GaUpn) }, "test"));
|
||||
|
||||
Assert.Equal(Viewed, TenantHelper.GetTenantId(Request(principal, ViewingContext())));
|
||||
Assert.Equal(Home, TenantHelper.GetTenantId(Request(principal, new RequestContext { TenantId = Home })));
|
||||
Assert.Equal(Home, TenantHelper.GetTenantId(Request(principal, context: null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_identity_tenant_stays_the_home_tenant()
|
||||
{
|
||||
Assert.Equal(Home, ViewingContext().IdentityTenantId());
|
||||
Assert.Equal(Viewed, new RequestContext { TenantId = Viewed }.IdentityTenantId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void View_as_never_triggers_a_recompute_although_its_admin_owns_the_viewed_tenant()
|
||||
{
|
||||
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ViewingContext(), Viewed));
|
||||
Assert.True(RecomputeTriggerGate.CanTriggerRecompute(ViewingContext() with { ViewAs = null }, Viewed));
|
||||
}
|
||||
|
||||
// ── counting at the real caller ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void View_as_records_no_presence_in_the_viewed_tenant()
|
||||
{
|
||||
Assert.False(UserPresenceMiddleware.Records(ViewingContext()));
|
||||
Assert.True(UserPresenceMiddleware.Records(ViewingContext() with { ViewAs = null }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_request_row_of_a_view_as_request_counts_at_the_home_tenant_as_global_admin()
|
||||
{
|
||||
var ctx = ViewingContext();
|
||||
var properties = new Dictionary<string, string>();
|
||||
|
||||
RequestTelemetryMiddleware.StampCaller(properties, ctx,
|
||||
RequestTelemetryMiddleware.ResolveTenantId(ctx.IdentityTenantId(), new Dictionary<object, object>(), "/api/sessions", null));
|
||||
|
||||
Assert.Equal(Home, properties[RequestRowDimensions.TenantId]);
|
||||
Assert.Equal(GaUpn, properties[RequestRowDimensions.UserId]);
|
||||
Assert.Equal(Constants.GlobalRoles.GlobalAdmin, properties[RequestRowDimensions.UserRole]);
|
||||
Assert.Equal(Viewed, properties[RequestRowDimensions.ViewAsTenantId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinary_request_row_carries_no_view_as_dimension()
|
||||
{
|
||||
var ctx = ViewingContext() with { TenantId = Home, ViewAs = null };
|
||||
var properties = new Dictionary<string, string>();
|
||||
|
||||
RequestTelemetryMiddleware.StampCaller(properties, ctx, ctx.TenantId);
|
||||
|
||||
Assert.Equal(Home, properties[RequestRowDimensions.TenantId]);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, properties[RequestRowDimensions.UserRole]);
|
||||
Assert.False(properties.ContainsKey(RequestRowDimensions.ViewAsTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Telemetry_items_are_stamped_only_inside_the_view_as_request_flow()
|
||||
{
|
||||
var sink = new CapturingProcessor();
|
||||
var processor = new ViewAsTelemetryProcessor(sink);
|
||||
|
||||
await InViewAsFlow(() => processor.Process(new EventTelemetry("inside")));
|
||||
processor.Process(new EventTelemetry("outside"));
|
||||
|
||||
var inside = (EventTelemetry)sink.Items[0];
|
||||
var outside = (EventTelemetry)sink.Items[1];
|
||||
Assert.Equal(Viewed, inside.Properties[RequestRowDimensions.ViewAsTenantId]);
|
||||
Assert.False(outside.Properties.ContainsKey(RequestRowDimensions.ViewAsTenantId));
|
||||
}
|
||||
|
||||
/// <summary>The scope opens inside an awaited method, like the middleware; it ends with that flow.</summary>
|
||||
private static async Task InViewAsFlow(Action emit)
|
||||
{
|
||||
await Task.Yield();
|
||||
ViewAsTelemetryProcessor.Enter(Viewed);
|
||||
emit();
|
||||
}
|
||||
|
||||
private sealed class CapturingProcessor : ITelemetryProcessor
|
||||
{
|
||||
public List<ITelemetry> Items { get; } = new();
|
||||
public void Process(ITelemetry item) => Items.Add(item);
|
||||
}
|
||||
|
||||
private static HttpRequestData Request(ClaimsPrincipal principal, RequestContext? context)
|
||||
{
|
||||
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
|
||||
if (context != null)
|
||||
items[RequestContext.ItemsKey] = context;
|
||||
var functionContext = new Mock<FunctionContext>();
|
||||
functionContext.SetupGet(c => c.Items).Returns(items);
|
||||
return new Mock<HttpRequestData>(functionContext.Object).Object;
|
||||
}
|
||||
}
|
||||
@@ -39,23 +39,26 @@
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="Azure.Monitor.Query" Version="1.5.0" />
|
||||
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.0.1" />
|
||||
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.51.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.1" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.1" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.4" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
|
||||
<PackageReference Include="Azure.Storage.Queues" Version="12.25.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.5" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.1.0" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.13.0" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
|
||||
<PackageReference Include="Azure.Storage.Queues" Version="12.28.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.16.1" />
|
||||
<!-- Transitive via Microsoft.Identity.Web's token cache, lifted past GHSA-846f-f764-76mx and
|
||||
GHSA-2fmv-rv2c-59x7. Remove once Microsoft.Identity.Web is 4.17.0 or later (it brings 10.0.13). -->
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+35
-84
@@ -88,44 +88,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
|
||||
{
|
||||
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
|
||||
|
||||
var records = new List<UserUsageRecord>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
records.Add(MapToRecord(entity));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null)
|
||||
{
|
||||
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
|
||||
|
||||
var records = new List<UserUsageRecord>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
records.Add(MapToRecord(entity));
|
||||
}
|
||||
|
||||
var grouped = records
|
||||
.GroupBy(r => r.Date)
|
||||
.Select(g => new UserUsageDailySummary
|
||||
{
|
||||
Date = g.Key,
|
||||
TenantId = tenantId,
|
||||
TotalRequests = g.Sum(r => r.RequestCount),
|
||||
UniqueUsers = g.Select(r => r.UserId).Distinct().Count(),
|
||||
UniqueEndpoints = g.Select(r => r.Endpoint).Distinct().Count(),
|
||||
})
|
||||
.OrderByDescending(s => s.Date)
|
||||
.ToList();
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the OData filter for a per-user usage query. <paramref name="userId"/> is a route
|
||||
/// segment (AAD oid) and the dates are query params, so all values MUST be OData-escaped before
|
||||
@@ -135,42 +97,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
internal static string BuildUserUsageFilter(string userId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(userId)}'";
|
||||
return AppendDateFilter(filter, dateFrom, dateTo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the OData filter for a per-tenant usage query. Same escaping requirement as
|
||||
/// <see cref="BuildUserUsageFilter"/>; returns null when no tenant/date scope is supplied.
|
||||
/// </summary>
|
||||
internal static string? BuildTenantUsageFilter(string? tenantId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
string? filter = !string.IsNullOrEmpty(tenantId)
|
||||
? $"TenantId eq '{ODataSanitizer.EscapeValue(tenantId)}'"
|
||||
: null;
|
||||
return AppendDateFilter(filter, dateFrom, dateTo);
|
||||
}
|
||||
|
||||
private static string AppendDateFilter(string? existingFilter, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = existingFilter ?? "";
|
||||
|
||||
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
|
||||
// so the value must still be OData-escaped before interpolation.
|
||||
if (!string.IsNullOrEmpty(dateFrom))
|
||||
{
|
||||
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
|
||||
// so the value must still be OData-escaped before interpolation.
|
||||
var dateVal = ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""));
|
||||
var clause = $"Date ge '{dateVal}'";
|
||||
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
|
||||
}
|
||||
|
||||
filter += $" and Date ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
|
||||
if (!string.IsNullOrEmpty(dateTo))
|
||||
{
|
||||
var dateVal = ODataSanitizer.EscapeValue(dateTo.Replace("-", ""));
|
||||
var clause = $"Date le '{dateVal}'";
|
||||
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
|
||||
}
|
||||
filter += $" and Date le '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}'";
|
||||
|
||||
return string.IsNullOrEmpty(filter) ? null! : filter;
|
||||
return filter;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteRecordsOlderThanAsync(string dateCutoff)
|
||||
@@ -251,20 +186,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return records;
|
||||
|
||||
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(filter: BuildTenantCounterFilter(tenantId, dateFrom, dateTo)))
|
||||
{
|
||||
records.Add(new TenantUsageRecord
|
||||
{
|
||||
TenantId = entity.PartitionKey,
|
||||
UserId = entity.GetString("UserId") ?? string.Empty,
|
||||
UserPrincipalName = entity.GetString("UserPrincipalName") ?? string.Empty,
|
||||
Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0],
|
||||
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
|
||||
LastRequestAt = entity.GetDateTimeOffset("LastRequestAt")?.UtcDateTime,
|
||||
});
|
||||
}
|
||||
records.Add(MapTenantRecord(entity));
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<TenantUsageRecord>> GetAllTenantUsageAsync(string dateFrom, string dateTo)
|
||||
{
|
||||
var records = new List<TenantUsageRecord>();
|
||||
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(filter: BuildDayRangeFilter(dateFrom, dateTo)))
|
||||
records.Add(MapTenantRecord(entity));
|
||||
return records;
|
||||
}
|
||||
|
||||
private static TenantUsageRecord MapTenantRecord(TableEntity entity) => new()
|
||||
{
|
||||
TenantId = entity.PartitionKey,
|
||||
UserId = entity.GetString("UserId") ?? string.Empty,
|
||||
UserPrincipalName = entity.GetString("UserPrincipalName") ?? string.Empty,
|
||||
Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0],
|
||||
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
|
||||
LastRequestAt = entity.GetDateTimeOffset("LastRequestAt")?.UtcDateTime,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Partition + RowKey range filter for McpTenantUsage: the RowKey starts with the yyyyMMdd day, so a
|
||||
/// date window is a key range ("{to}~" sorts after every "{to}_{oid}"); no property scan.
|
||||
@@ -272,11 +215,19 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
internal static string BuildTenantCounterFilter(string tenantId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
|
||||
var days = BuildDayRangeFilter(dateFrom, dateTo);
|
||||
return days.Length == 0 ? filter : $"{filter} and {days}";
|
||||
}
|
||||
|
||||
/// <summary>The RowKey day range of McpTenantUsage without a partition — empty when neither bound is set.</summary>
|
||||
internal static string BuildDayRangeFilter(string? dateFrom, string? dateTo)
|
||||
{
|
||||
var clauses = new List<string>(2);
|
||||
if (!string.IsNullOrEmpty(dateFrom))
|
||||
filter += $" and RowKey ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
|
||||
clauses.Add($"RowKey ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'");
|
||||
if (!string.IsNullOrEmpty(dateTo))
|
||||
filter += $" and RowKey lt '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}~'";
|
||||
return filter;
|
||||
clauses.Add($"RowKey lt '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}~'");
|
||||
return string.Join(" and ", clauses);
|
||||
}
|
||||
|
||||
public async Task<int> DeleteTenantRecordsOlderThanAsync(string dateCutoff)
|
||||
|
||||
+15
-5
@@ -162,8 +162,7 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
// here (flag-gated, probe-verified, idempotent) makes the status below resolve the newly
|
||||
// homed app naturally. Unbudgeted on purpose: this runs in the post-redirect callback,
|
||||
// where the consent-propagation retry chain is exactly what we want to ride.
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
var flip = await _appHomingService.TryAutoFlipToPrimaryAsync(tenantConfig, requestCtx.UserPrincipalName);
|
||||
var flip = await TryAutoFlipAsync(requestCtx);
|
||||
|
||||
var result = await _graphTokenService.GetConsentStatusAsync(requestCtx.TargetTenantId);
|
||||
|
||||
@@ -237,9 +236,7 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
// Consent-driven homing auto-flip BEFORE the fresh snapshot: the "detect existing
|
||||
// access" / reconcile path never touches consent-status, so this is its flip hook. A
|
||||
// budget-exhausted probe is transient ⇒ no flip; the frontend already renders "retry".
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
flip = await _appHomingService.TryAutoFlipToPrimaryAsync(
|
||||
tenantConfig, requestCtx.UserPrincipalName, budgetCts.Token);
|
||||
flip = await TryAutoFlipAsync(requestCtx, budgetCts.Token);
|
||||
|
||||
GraphPermissionSnapshot snapshot;
|
||||
try
|
||||
@@ -276,6 +273,19 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The consent-driven homing auto-flip. It writes the tenant's homing with the caller as actor plus an
|
||||
/// audit entry, so it never runs while a Global Admin only views the tenant (read-only "view as").
|
||||
/// </summary>
|
||||
private async Task<AppHomingAutoFlipResult> TryAutoFlipAsync(RequestContext requestCtx, CancellationToken ct = default)
|
||||
{
|
||||
if (requestCtx.ViewAs != null)
|
||||
return new AppHomingAutoFlipResult(AppHomingAutoFlipOutcome.NotEligible);
|
||||
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
return await _appHomingService.TryAutoFlipToPrimaryAsync(tenantConfig, requestCtx.UserPrincipalName, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision for the access-check probe: does the SP snapshot prove the core validation
|
||||
/// role is effectively granted? Extracted so the reconcile decision is unit testable without
|
||||
|
||||
@@ -110,6 +110,11 @@ public class AuthFunction
|
||||
return await req.BadRequestAsync("Missing required claims");
|
||||
}
|
||||
|
||||
// A Global Admin viewing a tenant as its member: that tenant's standing, none of the sign-in side effects.
|
||||
var requestContext = req.GetRequestContext();
|
||||
if (requestContext.ViewAs != null)
|
||||
return await GetViewAsUserAsync(req, principal, requestContext, upn, displayName, objectId);
|
||||
|
||||
// --- Parallel data fetch: all independent queries run concurrently ---
|
||||
// Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500.
|
||||
// This is intentional — the auth-decision queries are required; the What's-new marker read
|
||||
@@ -231,6 +236,53 @@ public class AuthFunction
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// auth/me while a Global Admin views a tenant as its member: the viewed tenant's standing (suspension,
|
||||
/// activation, Terms + DPA, plan) for the emulated member, through the same <see cref="BuildAuthResult"/>.
|
||||
/// None of the sign-in side effects run — onboarding, re-enable, client-id tracking, auto-admin, the login
|
||||
/// record and the push sign-in belong to the tenant's own members. Per-user state stays the caller's own:
|
||||
/// What's-new marks, MCP access and the homed app registration come from the home tenant.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseData> GetViewAsUserAsync(
|
||||
HttpRequestData req, System.Security.Claims.ClaimsPrincipal principal, RequestContext ctx,
|
||||
string upn, string? displayName, string? objectId)
|
||||
{
|
||||
var viewedTenantId = ctx.TenantId;
|
||||
var homeTenantId = ctx.ViewAs!.HomeTenantId;
|
||||
|
||||
var viewedConfigTask = _tenantConfigService.TryGetConfigurationAsync(viewedTenantId);
|
||||
var homeConfigTask = _tenantConfigService.TryGetConfigurationAsync(homeTenantId);
|
||||
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(viewedTenantId);
|
||||
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, homeTenantId, objectId, principal.GetAppRoles());
|
||||
var whatsNewSeenTask = _metricsRepo.GetUserWhatsNewSeenAsync(homeTenantId, upn);
|
||||
await Task.WhenAll(viewedConfigTask, homeConfigTask, isApprovedTask, mcpCheckTask, whatsNewSeenTask);
|
||||
|
||||
var (viewedConfig, _) = viewedConfigTask.Result;
|
||||
var (homeConfig, _) = homeConfigTask.Result;
|
||||
var whatsNewSeen = whatsNewSeenTask.Result;
|
||||
|
||||
var decision = BuildAuthResult(
|
||||
viewedConfig, isGlobalAdmin: false, isGlobalReader: false, isApprovedTask.Result,
|
||||
new MemberRoleInfo { Role = ctx.ViewAs.Role }, mcpCheckTask.Result, hasTenantAdmins: true,
|
||||
viewedTenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
|
||||
delegatedTenantIds: null,
|
||||
homedApp: _appRegistry.ResolveForTenant(homeConfig).IsLegacy ? "legacy" : "primary",
|
||||
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
|
||||
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc,
|
||||
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false,
|
||||
viewAs: new AuthMeViewAs
|
||||
{
|
||||
TenantId = viewedTenantId,
|
||||
TenantName = TenantDisplayName.Of(viewedConfig, viewedTenantId),
|
||||
Role = ctx.ViewAs.Role,
|
||||
HomeTenantId = homeTenantId,
|
||||
});
|
||||
|
||||
var response = req.CreateResponse(decision.StatusCode);
|
||||
await response.WriteAsJsonAsync(decision.Body);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PUT /api/auth/me/whats-new-seen
|
||||
/// Marks the caller's What's-new channel as seen.
|
||||
@@ -733,7 +785,8 @@ public class AuthFunction
|
||||
DateTime? whatsNewSeenPlatformUtc = null,
|
||||
DateTime? whatsNewSeenAgentUtc = null,
|
||||
bool mcpClientRegistrationEnabled = false,
|
||||
bool isExternalIdentity = false)
|
||||
bool isExternalIdentity = false,
|
||||
AuthMeViewAs? viewAs = null)
|
||||
{
|
||||
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
|
||||
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
|
||||
@@ -836,7 +889,8 @@ public class AuthFunction
|
||||
McpClientRegistrationEnabled = mcpClientRegistrationEnabled,
|
||||
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
|
||||
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc,
|
||||
DpaAcceptancePending = tenantConfig.DpaAcceptancePending
|
||||
DpaAcceptancePending = tenantConfig.DpaAcceptancePending,
|
||||
ViewAs = viewAs
|
||||
}, needsAutoAdmin);
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -56,10 +56,11 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
// revocation enforceable, and only inside the tenant whose grant was revoked
|
||||
// (LiveStreamRevoker). Both values come from the middleware-validated JWT, never from
|
||||
// client-controlled data. Without a principal key the connection has no user id: every
|
||||
// revocable join gate needs one.
|
||||
// revocable join gate needs one. Under view-as the binding stays the sign-in tenant
|
||||
// (IdentityTenantId): removing a Global Admin must cut their view-as tab as well.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var principalKey = string.IsNullOrEmpty(requestCtx.UserPrincipalName) ? null : requestCtx.UserPrincipalName;
|
||||
var negotiation = await _signalRService.NegotiateClientAsync(requestCtx.TenantId, principalKey);
|
||||
var negotiation = await _signalRService.NegotiateClientAsync(requestCtx.IdentityTenantId(), principalKey);
|
||||
if (negotiation == null)
|
||||
{
|
||||
return await req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable, "SignalR negotiation unavailable");
|
||||
|
||||
+62
-93
@@ -22,17 +22,70 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
private readonly IUserUsageRepository _userUsageRepo;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly McpQuotaService _quotaService;
|
||||
private readonly McpUsageOverviewService _overviewService;
|
||||
|
||||
public McpUsageMetricsFunction(
|
||||
ILogger<McpUsageMetricsFunction> logger,
|
||||
IUserUsageRepository userUsageRepo,
|
||||
McpUserService mcpUserService,
|
||||
McpQuotaService quotaService)
|
||||
McpQuotaService quotaService,
|
||||
McpUsageOverviewService overviewService)
|
||||
{
|
||||
_logger = logger;
|
||||
_userUsageRepo = userUsageRepo;
|
||||
_mcpUserService = mcpUserService;
|
||||
_quotaService = quotaService;
|
||||
_overviewService = overviewService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/mcp-usage/overview?days=&platformAdmins= — the all-tenant MCP usage overview of
|
||||
/// the Global Admin metrics page: tenants with accounts and quota state, the tool ranking, quota refusals,
|
||||
/// per-minute peaks and the daily trend (see <see cref="McpUsageOverviewService"/>). <c>days</c> = 1–90 (the
|
||||
/// telemetry keeps 90 days), default 30; <c>platformAdmins=include</c> counts Global Admins and Global Readers,
|
||||
/// left out by default.
|
||||
/// </summary>
|
||||
[Function("GetGlobalMcpUsageOverview")]
|
||||
public async Task<HttpResponseData> GetGlobalUsageOverview(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/overview")] HttpRequestData req)
|
||||
{
|
||||
var query = ParseOverviewQuery(req.Query["days"], req.Query["platformAdmins"]);
|
||||
if (query.Error != null)
|
||||
{
|
||||
return await req.BadRequestAsync(query.Error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var overview = await _overviewService.BuildAsync(query.Days, query.IncludePlatformAdmins);
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(overview);
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: the overview's query string. <c>days</c> defaults to 30 and must be an integer from 1 to
|
||||
/// <see cref="McpUsageOverviewService.MaxDays"/>; <c>platformAdmins</c> is <c>include</c> or <c>exclude</c>
|
||||
/// (the default). Anything else names the problem in <c>Error</c>.
|
||||
/// </summary>
|
||||
internal static (int Days, bool IncludePlatformAdmins, string? Error) ParseOverviewQuery(string? rawDays, string? platformAdmins)
|
||||
{
|
||||
if (!QueryParams.TryInt(rawDays, "days", 1, McpUsageOverviewService.MaxDays, out var days, out var error))
|
||||
{
|
||||
return (0, false, error);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(platformAdmins) && platformAdmins is not ("include" or "exclude"))
|
||||
{
|
||||
return (0, false, "platformAdmins must be include or exclude");
|
||||
}
|
||||
|
||||
return (days ?? 30, platformAdmins == "include", null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -65,7 +118,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
// Effective quota state: resolved plan (per-user override → tenant edition),
|
||||
// limits (SectionUsagePlans definition → catalog fallback) and current counters.
|
||||
var quota = await _quotaService.CheckAsync(userId, upn, tenantId);
|
||||
// A read: viewing the page is not an MCP request and must not count as one.
|
||||
var quota = await _quotaService.ReadStateAsync(userId, upn, tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetMyMcpUsageResponse
|
||||
@@ -86,11 +140,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
TenantMonthlyLimit = quota.TenantMonthlyLimit,
|
||||
TenantDailyUsed = quota.TenantDailyUsed,
|
||||
TenantMonthlyUsed = quota.TenantMonthlyUsed,
|
||||
PurchasedDelegatedSlots = WithSlots(quota.PurchasedDelegatedSlots, quota.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotDailyLimit),
|
||||
SlotMonthlyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotMonthlyLimit),
|
||||
SlotTenantDailyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantDailyLimit),
|
||||
SlotTenantMonthlyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantMonthlyLimit),
|
||||
PurchasedDelegatedSlots = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotDailyLimit),
|
||||
SlotMonthlyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotMonthlyLimit),
|
||||
SlotTenantDailyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantDailyLimit),
|
||||
SlotTenantMonthlyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantMonthlyLimit),
|
||||
},
|
||||
Records = records
|
||||
});
|
||||
@@ -164,7 +218,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
DateFrom = dateFrom,
|
||||
DateTo = dateTo,
|
||||
Users = AggregateOrganizationUsage(records, dateFrom, dateTo, today, monthStart),
|
||||
Quota = BuildOrganizationQuota(records, limits, today, monthStart),
|
||||
Quota = McpQuotaNodes.BuildOrganizationQuota(records, limits, today, monthStart),
|
||||
Daily = BuildOrganizationDaily(records, dateFrom, dateTo),
|
||||
});
|
||||
return response;
|
||||
@@ -190,37 +244,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return byDay.Select(kv => new McpOrganizationDailyItem { Date = kv.Key, Requests = kv.Value }).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: the tenant's organization windows from the same counter rows — today's and this month's total over
|
||||
/// every account charged to the tenant, against the tenant plan's limits (0 = unlimited).
|
||||
/// </summary>
|
||||
internal static McpOrganizationQuotaNode BuildOrganizationQuota(
|
||||
IEnumerable<TenantUsageRecord> records, McpQuotaService.TenantPlanLimits limits, string today, string monthStart)
|
||||
{
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
foreach (var record in records)
|
||||
{
|
||||
if (record.Date == today) dailyUsed += record.RequestCount;
|
||||
if (string.CompareOrdinal(record.Date, monthStart) >= 0 && string.CompareOrdinal(record.Date, today) <= 0)
|
||||
monthlyUsed += record.RequestCount;
|
||||
}
|
||||
|
||||
return new McpOrganizationQuotaNode
|
||||
{
|
||||
TenantPlan = limits.TenantPlan,
|
||||
DailyLimit = limits.TenantDailyLimit,
|
||||
MonthlyLimit = limits.TenantMonthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
PurchasedDelegatedSlots = WithSlots(limits.PurchasedDelegatedSlots, limits.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantDailyLimit),
|
||||
SlotMonthlyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantMonthlyLimit),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The slot breakdown is only worth sending when a slot was actually purchased.</summary>
|
||||
private static int? WithSlots(int purchasedSlots, int value) => purchasedSlots > 0 ? value : null;
|
||||
|
||||
/// <summary>
|
||||
/// Pure: folds the organization counter rows into one item per account, ordered by this month's usage.
|
||||
/// </summary>
|
||||
@@ -318,59 +341,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/mcp-usage?tenantId=&dateFrom=&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=&dateFrom=&dateTo= — Daily summaries
|
||||
/// </summary>
|
||||
[Function("GetGlobalMcpUsageDaily")]
|
||||
public async Task<HttpResponseData> GetGlobalUsageDaily(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/daily")] HttpRequestData req)
|
||||
{
|
||||
_logger.LogInformation("Global MCP daily usage requested");
|
||||
|
||||
try
|
||||
{
|
||||
var tenantId = req.Query["tenantId"];
|
||||
var dateFrom = req.Query["dateFrom"];
|
||||
var dateTo = req.Query["dateTo"];
|
||||
|
||||
var summaries = await _userUsageRepo.GetDailySummaryAsync(tenantId, dateFrom, dateTo);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
/// </summary>
|
||||
public class AccessProbeFunction
|
||||
{
|
||||
[Function("GlobalAccessProbe")]
|
||||
/// <summary>Its request rows carry a tool name for a call that never ran; usage counts leave them out by it.</summary>
|
||||
internal const string FunctionName = "GlobalAccessProbe";
|
||||
|
||||
[Function(FunctionName)]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/access-probe")] HttpRequestData req,
|
||||
FunctionContext context)
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Azure.Core;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -43,19 +37,16 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
|
||||
private readonly ILogger<AppInsightsQueryFunction> _logger;
|
||||
private readonly LogQuerySourceCatalog _sources;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly TokenCredential _credential;
|
||||
private readonly LogQueryExecutor _executor;
|
||||
|
||||
public AppInsightsQueryFunction(
|
||||
ILogger<AppInsightsQueryFunction> logger,
|
||||
LogQuerySourceCatalog sources,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
TokenCredential credential)
|
||||
LogQueryExecutor executor)
|
||||
{
|
||||
_logger = logger;
|
||||
_sources = sources;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credential = credential;
|
||||
_executor = executor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -88,63 +79,49 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
|
||||
var timespan = string.IsNullOrWhiteSpace(body.Timespan) ? "PT1H" : body.Timespan.Trim();
|
||||
var budget = ClampBudget(body.BudgetSeconds);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budget));
|
||||
var outcome = await _executor.ExecuteAsync(source!, body.Query, timespan, budget);
|
||||
|
||||
var token = await _credential.GetTokenAsync(new TokenRequestContext(new[] { source!.TokenScope }), cts.Token);
|
||||
|
||||
using var upstreamRequest = new HttpRequestMessage(HttpMethod.Post, source.QueryUri);
|
||||
upstreamRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
// Ask the store to keep working as long as we are willing to wait (default server wait is shorter).
|
||||
upstreamRequest.Headers.TryAddWithoutValidation("Prefer", $"wait={budget}");
|
||||
upstreamRequest.Content = new StringContent(
|
||||
JsonSerializer.Serialize(new KustoQuery(body.Query, timespan)), Encoding.UTF8, "application/json");
|
||||
|
||||
var client = _httpClientFactory.CreateClient(LogQuerySourceCatalog.HttpClientName);
|
||||
using var upstreamResponse = await client.SendAsync(upstreamRequest, cts.Token);
|
||||
var responseText = await upstreamResponse.Content.ReadAsStringAsync(cts.Token);
|
||||
stopwatch.Stop();
|
||||
|
||||
if (!upstreamResponse.IsSuccessStatusCode)
|
||||
if (!outcome.Succeeded)
|
||||
{
|
||||
var upstreamStatus = outcome.UpstreamStatus ?? HttpStatusCode.BadGateway;
|
||||
var responseText = outcome.UpstreamBody ?? string.Empty;
|
||||
_logger.LogWarning("Log query ({Source}) failed: {StatusCode} {Response}",
|
||||
source.Name, upstreamResponse.StatusCode, LogSanitizer.Clean(Cap(responseText, 2048)));
|
||||
source!.Name, upstreamStatus, LogSanitizer.Clean(Cap(responseText, 2048)));
|
||||
|
||||
var (errorMessage, errorCode) = ExtractError(responseText);
|
||||
var (errorMessage, errorCode) = LogQueryExecutor.ExtractError(responseText);
|
||||
// A query the store rejected is the CALLER's error (400): the MCP renders 4xx bodies in
|
||||
// full (message, hint, upstream) but sanitizes every 5xx down to the error code — and a
|
||||
// KQL syntax error hidden behind "retry later" is exactly the parity loss this proxy
|
||||
// exists to avoid. Store-side failures (5xx) and a missing read grant (401/403 — the
|
||||
// managed identity, not the caller) stay 502.
|
||||
var status = MapUpstreamFailure(upstreamResponse.StatusCode);
|
||||
var status = MapUpstreamFailure(upstreamStatus);
|
||||
return await req.ErrorAsync(status, new QueryBackendLogsErrorResponse
|
||||
{
|
||||
Error = errorMessage ?? "The telemetry store rejected the query.",
|
||||
Code = ApiErrorWriter.DefaultCode(status),
|
||||
UpstreamCode = errorCode,
|
||||
StatusCode = (int)upstreamResponse.StatusCode,
|
||||
StatusCode = (int)upstreamStatus,
|
||||
Source = source.Name,
|
||||
Hint = UpstreamHint(upstreamResponse.StatusCode, errorCode, source.Name),
|
||||
Hint = UpstreamHint(upstreamStatus, errorCode, source.Name),
|
||||
// Everything the store said, so nothing the CLI would print is lost.
|
||||
Upstream = Cap(responseText, UpstreamErrorCap),
|
||||
});
|
||||
}
|
||||
|
||||
var (tables, partialReason) = ParseKustoBody(responseText);
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new QueryBackendLogsResponse
|
||||
{
|
||||
Success = true,
|
||||
Source = source.Name,
|
||||
Source = source!.Name,
|
||||
Timespan = timespan,
|
||||
BudgetSeconds = budget,
|
||||
ElapsedMs = stopwatch.ElapsedMilliseconds,
|
||||
Partial = partialReason != null ? true : null,
|
||||
PartialReason = partialReason,
|
||||
Tables = tables,
|
||||
ElapsedMs = outcome.ElapsedMs,
|
||||
Partial = outcome.PartialReason != null ? true : null,
|
||||
PartialReason = outcome.PartialReason,
|
||||
Tables = outcome.Tables!,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -196,110 +173,6 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
return Math.Clamp(requested.Value, MinBudgetSeconds, MaxBudgetSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kusto REST success body → typed tables plus the store's partial-result note. A 200 can carry
|
||||
/// <c>"error": { "code": "PartialError", ... }</c> next to <c>tables</c> when the result was cut
|
||||
/// (size cap, shard timeout); that used to pass through unread. Cells are cloned out of the
|
||||
/// document so they outlive it.
|
||||
/// </summary>
|
||||
internal static (IReadOnlyList<KqlTable> Tables, string? PartialReason) ParseKustoBody(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var tables = new List<KqlTable>();
|
||||
|
||||
if (root.TryGetProperty("tables", out var tablesEl) && tablesEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var t in tablesEl.EnumerateArray())
|
||||
{
|
||||
var columns = new List<KqlColumn>();
|
||||
if (t.TryGetProperty("columns", out var colsEl) && colsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var c in colsEl.EnumerateArray())
|
||||
{
|
||||
columns.Add(new KqlColumn
|
||||
{
|
||||
Name = c.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty,
|
||||
Type = c.TryGetProperty("type", out var ty) ? ty.GetString() ?? string.Empty : string.Empty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var rows = new List<IReadOnlyList<JsonElement>>();
|
||||
if (t.TryGetProperty("rows", out var rowsEl) && rowsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var r in rowsEl.EnumerateArray())
|
||||
{
|
||||
var cells = new List<JsonElement>(columns.Count);
|
||||
if (r.ValueKind == JsonValueKind.Array)
|
||||
foreach (var cell in r.EnumerateArray())
|
||||
cells.Add(cell.Clone());
|
||||
rows.Add(cells);
|
||||
}
|
||||
}
|
||||
|
||||
tables.Add(new KqlTable
|
||||
{
|
||||
Name = t.TryGetProperty("name", out var name) ? name.GetString() ?? string.Empty : string.Empty,
|
||||
Columns = columns,
|
||||
Rows = rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
string? partialReason = null;
|
||||
if (root.TryGetProperty("error", out var errorEl) && errorEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var (message, code) = ExtractError(errorEl);
|
||||
partialReason = string.IsNullOrEmpty(code) ? message : $"{code}: {message}";
|
||||
}
|
||||
|
||||
return (tables, partialReason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message + code from a Kusto error body — <c>innererror</c> first (the specific reason), then the
|
||||
/// outer <c>error</c>. Tolerates non-JSON.
|
||||
/// </summary>
|
||||
internal static (string Message, string? Code) ExtractError(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("error", out var errorObj) && errorObj.ValueKind == JsonValueKind.Object)
|
||||
return ExtractError(errorObj);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// not JSON — keep the generic message
|
||||
}
|
||||
return ("Telemetry store query failed", null);
|
||||
}
|
||||
|
||||
private static (string Message, string? Code) ExtractError(JsonElement errorObj)
|
||||
{
|
||||
var message = "Telemetry store query failed";
|
||||
string? code = null;
|
||||
|
||||
if (errorObj.TryGetProperty("innererror", out var inner) && inner.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (inner.TryGetProperty("message", out var innerMsg) && innerMsg.ValueKind == JsonValueKind.String)
|
||||
message = innerMsg.GetString() ?? message;
|
||||
if (inner.TryGetProperty("code", out var innerCode) && innerCode.ValueKind == JsonValueKind.String)
|
||||
code = innerCode.GetString();
|
||||
}
|
||||
if (message == "Telemetry store query failed" && errorObj.TryGetProperty("message", out var msg) && msg.ValueKind == JsonValueKind.String)
|
||||
message = msg.GetString() ?? message;
|
||||
if (code == null && errorObj.TryGetProperty("code", out var outerCode) && outerCode.ValueKind == JsonValueKind.String)
|
||||
code = outerCode.GetString();
|
||||
|
||||
return (message, code);
|
||||
}
|
||||
|
||||
private static string Cap(string s, int max) => s.Length <= max ? s : s[..max] + "…";
|
||||
/// <summary>Kusto REST request body — both App Insights and Log Analytics accept exactly this.</summary>
|
||||
private sealed record KustoQuery(
|
||||
[property: JsonPropertyName("query")] string Query,
|
||||
[property: JsonPropertyName("timespan")] string Timespan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
// keeps a read-only GlobalReader from steering cross-tenant writes).
|
||||
if (reanalyze && !RecomputeTriggerGate.CanTriggerRecompute(requestCtx, effectiveTenantId))
|
||||
{
|
||||
return await req.ForbiddenAsync("Your role does not permit triggering re-analysis.");
|
||||
return await RecomputeTriggerGate.RefuseAsync(req, requestCtx, "Your role does not permit triggering re-analysis.");
|
||||
}
|
||||
|
||||
// Rules whose StoreRuleResultAsync returned false during the on-demand reanalyze.
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
// gate it to write-capable callers (see RecomputeTriggerGate).
|
||||
if (rescan && !RecomputeTriggerGate.CanTriggerRecompute(requestCtx, effectiveTenantId))
|
||||
{
|
||||
return await req.ForbiddenAsync("Your role does not permit triggering a vulnerability re-scan.");
|
||||
return await RecomputeTriggerGate.RefuseAsync(req, requestCtx, "Your role does not permit triggering a vulnerability re-scan.");
|
||||
}
|
||||
|
||||
if (rescan)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
@@ -16,9 +18,13 @@ public static class RecomputeTriggerGate
|
||||
/// True when the caller may trigger a recompute for <paramref name="effectiveTenantId"/>:
|
||||
/// a Global Admin anywhere, or an OWN-TENANT Admin/Operator. Viewer, Global Reader, and
|
||||
/// delegated (read-tier) callers are excluded, as is any tenant role applied cross-tenant.
|
||||
/// View as tenant is read-only, and its emulated Admin would pass every other check.
|
||||
/// </summary>
|
||||
public static bool CanTriggerRecompute(RequestContext ctx, string effectiveTenantId)
|
||||
{
|
||||
if (ctx.ViewAs != null)
|
||||
return false;
|
||||
|
||||
if (ctx.IsGlobalAdmin)
|
||||
return true;
|
||||
|
||||
@@ -26,4 +32,10 @@ public static class RecomputeTriggerGate
|
||||
return isOwnTenant
|
||||
&& (ctx.UserRole == Constants.TenantRoles.Admin || ctx.UserRole == Constants.TenantRoles.Operator);
|
||||
}
|
||||
|
||||
/// <summary>The 403 for a refused trigger: the view-as answer, else the role message of the route.</summary>
|
||||
public static Task<HttpResponseData> RefuseAsync(HttpRequestData req, RequestContext ctx, string roleMessage)
|
||||
=> ctx.ViewAs != null
|
||||
? req.ForbiddenAsync(ViewAsTarget.ReadOnlyMessage, Constants.ApiErrorCodes.ViewAsReadOnly)
|
||||
: req.ForbiddenAsync(roleMessage);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ public sealed record RequestContext
|
||||
{
|
||||
internal const string ItemsKey = "RequestContext";
|
||||
|
||||
/// <summary>The user's Azure AD tenant ID (from JWT tid claim).</summary>
|
||||
/// <summary>
|
||||
/// The tenant the request acts in: the user's Azure AD tenant ID (JWT tid claim), or the viewed tenant
|
||||
/// under <see cref="ViewAs"/>. Where the caller's IDENTITY is meant (bindings, presence, attribution),
|
||||
/// use <see cref="RequestContextExtensions.IdentityTenantId"/>.
|
||||
/// </summary>
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>The user's UPN (from JWT upn/preferred_username claim). Display/audit/row-key use — mutable.</summary>
|
||||
@@ -111,13 +115,30 @@ public sealed record RequestContext
|
||||
/// Handlers should use this for data access instead of extracting tenantId from route params manually.
|
||||
/// </summary>
|
||||
public string TargetTenantId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Set when a Global Admin views <see cref="TenantId"/> as one of its members (read-only "view as"):
|
||||
/// every other field then describes that emulated member, while this keeps the real caller's home
|
||||
/// tenant. Null on every ordinary request.
|
||||
/// </summary>
|
||||
public ViewAsContext? ViewAs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>The real caller behind a view-as request: its home tenant (JWT tid) and the emulated tenant role.</summary>
|
||||
public sealed record ViewAsContext(string HomeTenantId, string Role);
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for accessing the resolved RequestContext.
|
||||
/// </summary>
|
||||
public static class RequestContextExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The tenant the caller's identity is bound to: the JWT tenant, also under view-as. Use it where the
|
||||
/// caller is identified (SignalR user id, presence, telemetry attribution), never for the data scope.
|
||||
/// </summary>
|
||||
public static string IdentityTenantId(this RequestContext ctx)
|
||||
=> ctx.ViewAs?.HomeTenantId ?? ctx.TenantId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved RequestContext from FunctionContext.Items.
|
||||
/// Populated by PolicyEnforcementMiddleware for authenticated requests.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Names of the request-row dimensions <c>RequestTelemetryMiddleware</c> writes on every API call that the
|
||||
/// backend itself reads back with KQL (the MCP usage overview). Writer and reader use these constants, so a
|
||||
/// rename cannot silently empty a query.
|
||||
/// </summary>
|
||||
internal static class RequestRowDimensions
|
||||
{
|
||||
public const string Source = "Source";
|
||||
/// <summary>The value of <see cref="Source"/> on the worker's canonical request row.</summary>
|
||||
public const string WorkerMiddleware = "WorkerMiddleware";
|
||||
public const string FunctionName = "FunctionName";
|
||||
public const string HttpMethod = "HttpMethod";
|
||||
public const string HttpPath = "HttpPath";
|
||||
public const string ClientSource = "ClientSource";
|
||||
public const string McpToolName = "McpToolName";
|
||||
public const string CorrelationId = "CorrelationId";
|
||||
public const string TenantId = "TenantId";
|
||||
/// <summary>The caller's UPN, or <c>app:<client-id></c> for an application principal.</summary>
|
||||
public const string UserId = "UserId";
|
||||
public const string UserRole = "UserRole";
|
||||
/// <summary>
|
||||
/// The tenant a Global Admin viewed as its member (read-only "view as"). Set on the request row and on every
|
||||
/// item the request emits; TenantId/UserRole of such a row stay the real caller's, never the viewed tenant's.
|
||||
/// </summary>
|
||||
public const string ViewAsTenantId = "ViewAsTenantId";
|
||||
}
|
||||
@@ -44,6 +44,11 @@ public static class TenantHelper
|
||||
|
||||
public static string GetTenantId(HttpRequestData req)
|
||||
{
|
||||
// A view-as request acts in the viewed tenant; only PolicyEnforcementMiddleware sets it, for a Global Admin.
|
||||
var context = req.FunctionContext.GetRequestContext();
|
||||
if (context.ViewAs != null)
|
||||
return context.TenantId;
|
||||
|
||||
var user = GetAuthenticatedPrincipal(req);
|
||||
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Reads Microsoft Graph and Entra token-endpoint responses with System.Text.Json (D-420). The members keep
|
||||
/// the outcomes the former Newtonsoft code had for every realistic body; GraphResponseParsingTests holds the
|
||||
/// contract, including the deliberate differences.
|
||||
/// </summary>
|
||||
internal static class UpstreamJson
|
||||
{
|
||||
// Newtonsoft skipped comments and accepted trailing commas; keep both.
|
||||
private static readonly JsonDocumentOptions ReadOptions = new()
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The body's root object, or null for an empty or blank body and for a JSON null root. Malformed JSON,
|
||||
/// trailing content and any other root kind throw <see cref="JsonException"/>, which is what the callers'
|
||||
/// existing catch blocks expect.
|
||||
/// </summary>
|
||||
internal static JsonElement? ParseObjectOrNull(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return null;
|
||||
|
||||
using var document = JsonDocument.Parse(body, ReadOptions);
|
||||
var root = document.RootElement;
|
||||
return root.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => root.Clone(),
|
||||
JsonValueKind.Null => null,
|
||||
_ => throw new JsonException($"Expected a JSON object, got {root.ValueKind}."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The property's text; null when it is absent, JSON null, not a string, or text whose escapes are no valid
|
||||
/// UTF-16. <paramref name="obj"/> must be an object: a list entry of another kind throws
|
||||
/// <see cref="InvalidOperationException"/>, which the validators turn into a transient result, as before.
|
||||
/// </summary>
|
||||
internal static string? GetString(JsonElement obj, string name)
|
||||
=> obj.TryGetProperty(name, out var value) ? TextOf(value) : null;
|
||||
|
||||
// A lone surrogate escape such as \ud800 makes GetString throw; it reads as absent like any other unusable
|
||||
// value, so one corrupt field cannot turn a whole lookup transient.
|
||||
private static string? TextOf(JsonElement value)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.String)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The entries of an array property, of any kind; empty when it is absent or not an array.</summary>
|
||||
internal static IEnumerable<JsonElement> Items(JsonElement obj, string name)
|
||||
=> obj.TryGetProperty(name, out var array) && array.ValueKind == JsonValueKind.Array
|
||||
? array.EnumerateArray()
|
||||
: Enumerable.Empty<JsonElement>();
|
||||
|
||||
/// <summary>
|
||||
/// The page's <c>@odata.nextLink</c>; null when absent or JSON null. Any other value, text that is no valid
|
||||
/// UTF-16 included, throws <see cref="JsonException"/>, so a scan never ends as complete on a link it cannot
|
||||
/// follow.
|
||||
/// </summary>
|
||||
internal static string? GetNextLink(JsonElement page)
|
||||
{
|
||||
if (!page.TryGetProperty("@odata.nextLink", out var link) || link.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
return TextOf(link) ?? throw new JsonException($"@odata.nextLink is no readable text ({link.ValueKind}).");
|
||||
}
|
||||
|
||||
/// <summary>A whole number, or a string holding one (Entra v1 and ADFS send expires_in as text); else null.</summary>
|
||||
internal static long? GetInt64(JsonElement obj, string name)
|
||||
{
|
||||
if (!obj.TryGetProperty(name, out var value))
|
||||
return null;
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when value.TryGetInt64(out var number) => number,
|
||||
JsonValueKind.String when long.TryParse(TextOf(value), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>true or false; any other kind, "true" in quotes or 1 included, is null.</summary>
|
||||
internal static bool? GetBoolean(JsonElement obj, string name)
|
||||
=> obj.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? value.GetBoolean()
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// An ISO date string as a UTC instant, read with the invariant culture and full precision; a value
|
||||
/// without an offset counts as UTC. Null when absent, not a string, blank, unreadable, or Graph's
|
||||
/// <c>0001-01-01</c> placeholder for an unset date.
|
||||
/// </summary>
|
||||
internal static DateTimeOffset? GetUtcDateTime(JsonElement obj, string name)
|
||||
{
|
||||
var raw = GetString(obj, name);
|
||||
if (string.IsNullOrWhiteSpace(raw)
|
||||
|| !DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed))
|
||||
return null;
|
||||
return parsed.Year <= 1 ? null : parsed;
|
||||
}
|
||||
}
|
||||
+48
-13
@@ -35,33 +35,44 @@ namespace AutopilotMonitor.Functions.Middleware;
|
||||
/// request can never be blocked by its OWN in-flight increment. The increment stays
|
||||
/// fire-and-forget (never blocks the request path).
|
||||
///
|
||||
/// <b>The quota boundary is deliberately SOFT, not exact.</b> McpQuotaService caches the usage
|
||||
/// snapshots for 60 seconds (per instance; per user and per tenant), so an ALLOWED decision keeps
|
||||
/// admitting requests inside that window even after the async increments push the stored counters past
|
||||
/// the limit. Worst-case overshoot is bounded: ~60s × the request rate against that counter (× instance
|
||||
/// count on scaled-out Flex Consumption) — the same deliberate posture as the sliding-window
|
||||
/// rate limiter, trading exactness for one counter read per user / per tenant per minute instead of per
|
||||
/// request. Pinned by McpQuotaServiceTests soft-boundary tests; do NOT re-document this as an
|
||||
/// exact limit without reworking the snapshot cache.
|
||||
/// <b>The quota boundary is exact per instance, soft across instances.</b> McpQuotaService reads the
|
||||
/// counters from storage at most every 60 seconds per account and per organization and counts every request
|
||||
/// it admits on them at once, so one instance blocks at the limit. Other instances learn of those requests only
|
||||
/// with their next read, so on scaled-out Flex Consumption the overshoot is what the other instances admitted
|
||||
/// inside 60 seconds. Pinned by the McpQuotaServiceTests boundary tests; the customer docs never call the limit exact.
|
||||
///
|
||||
/// Over-quota requests get 429 with a structured body, <c>Retry-After</c>, and
|
||||
/// <c>X-MCP-Quota-*</c> headers; allowed MCP requests get the quota headers too so the MCP
|
||||
/// client can surface remaining budget. Fail-open on storage/counter errors (handled inside
|
||||
/// <see cref="McpQuotaService"/>), fail-closed on plan resolution (unknown plan → Community).
|
||||
/// client can surface remaining budget. The first refusal per account or organization, window and period
|
||||
/// on an instance raises the ops event <c>McpQuotaExceeded</c>; every refusal writes the Warning line.
|
||||
/// Fail-open on storage/counter errors (handled inside <see cref="McpQuotaService"/>), fail-closed on plan
|
||||
/// resolution (unknown plan → Community).
|
||||
/// </summary>
|
||||
public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
/// <summary>Start of the Warning line every refused request writes; the MCP usage overview finds the refusals by it.</summary>
|
||||
internal const string BlockedLogPrefix = "[McpQuota] BLOCKED";
|
||||
|
||||
private const string BlockedLogTemplate = BlockedLogPrefix
|
||||
+ " oid={Oid} charged={ChargedTenant} level={Level} scope={Scope} plan={Plan} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit} tenantDaily={TenantDailyUsed}/{TenantDailyLimit} tenantMonthly={TenantMonthlyUsed}/{TenantMonthlyLimit}";
|
||||
|
||||
// The key carries the period (see ExceededAlertKey), so the interval only has to outlast the longest one.
|
||||
private static readonly LogThrottle ExceededAlerts = new(TimeSpan.FromDays(31));
|
||||
|
||||
private readonly McpQuotaService _quotaService;
|
||||
private readonly IUserUsageRepository _userUsageRepo;
|
||||
private readonly OpsEventService _opsEvents;
|
||||
private readonly ILogger<McpQuotaEnforcementMiddleware> _logger;
|
||||
|
||||
public McpQuotaEnforcementMiddleware(
|
||||
McpQuotaService quotaService,
|
||||
IUserUsageRepository userUsageRepo,
|
||||
OpsEventService opsEvents,
|
||||
ILogger<McpQuotaEnforcementMiddleware> logger)
|
||||
{
|
||||
_quotaService = quotaService;
|
||||
_userUsageRepo = userUsageRepo;
|
||||
_opsEvents = opsEvents;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -104,7 +115,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
McpQuotaDecision decision;
|
||||
try
|
||||
{
|
||||
decision = await _quotaService.CheckAsync(oid, upn, homeTenantId);
|
||||
decision = await _quotaService.AdmitAsync(oid, upn, homeTenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -119,16 +130,40 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
if (decision.Allowed)
|
||||
{
|
||||
// Check-then-increment: the decision above reflects previously SERVED requests only.
|
||||
// The check already counted this request on the instance's counters; storage gets it here.
|
||||
TrackUsage(httpContext, oid, upn, homeTenantId);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
LogBlocked(oid, homeTenantId, decision);
|
||||
RaiseExceeded(oid, upn, homeTenantId, decision);
|
||||
await WriteExceededAsync(context, httpContext, decision, BuildExceededResponse(decision));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: one key per account (level user) or organization (level tenant), window and period. The period is
|
||||
/// the window's reset date, so the next day or month starts a fresh key.
|
||||
/// </summary>
|
||||
internal static string ExceededAlertKey(string oid, string tenantId, McpQuotaDecision decision)
|
||||
{
|
||||
var subject = decision.Level == McpQuotaLevel.Tenant ? tenantId.ToLowerInvariant() : oid;
|
||||
return $"{decision.Level}:{decision.Scope}:{subject}:{decision.ResetUtc:yyyyMMdd}";
|
||||
}
|
||||
|
||||
/// <summary>The ops event for the first refusal of its key on this instance — fire-and-forget, never on the 429's path.</summary>
|
||||
private void RaiseExceeded(string oid, string? upn, string tenantId, McpQuotaDecision decision)
|
||||
{
|
||||
if (!ExceededAlerts.ShouldLog(ExceededAlertKey(oid, tenantId, decision)))
|
||||
return;
|
||||
|
||||
var level = decision.Level ?? McpQuotaLevel.User;
|
||||
_ = _opsEvents.RecordMcpQuotaExceededAsync(
|
||||
tenantId, upn ?? oid, level, decision.Scope ?? "daily",
|
||||
level == McpQuotaLevel.Tenant ? decision.TenantPlan : decision.Plan,
|
||||
decision.ExceededLimit, decision.ExceededUsed, decision.ResetUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: who bypasses the check. Only the Global Admin — platform operations must never be blocked by a
|
||||
/// budget. The Global Reader is a customer-facing read role and stays within its home tenant's windows.
|
||||
@@ -182,7 +217,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
private void LogBlocked(string oid, string tenantId, McpQuotaDecision decision)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[McpQuota] BLOCKED oid={Oid} charged={ChargedTenant} level={Level} scope={Scope} plan={Plan} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit} tenantDaily={TenantDailyUsed}/{TenantDailyLimit} tenantMonthly={TenantMonthlyUsed}/{TenantMonthlyLimit}",
|
||||
BlockedLogTemplate,
|
||||
oid, LogSanitizer.Clean(tenantId), decision.Level, decision.Scope, decision.Plan, decision.DailyUsed, decision.DailyLimit, decision.MonthlyUsed, decision.MonthlyLimit,
|
||||
decision.TenantDailyUsed, decision.TenantDailyLimit, decision.TenantMonthlyUsed, decision.TenantMonthlyLimit);
|
||||
}
|
||||
|
||||
@@ -92,12 +92,16 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
principal = cp;
|
||||
}
|
||||
|
||||
var viewAs = ViewAsTarget.FromHeaders(
|
||||
httpContext.Request.Headers[ViewAsHeaders.Tenant].FirstOrDefault(),
|
||||
httpContext.Request.Headers[ViewAsHeaders.Role].FirstOrDefault());
|
||||
|
||||
// All authorization + RequestContext construction is the transport-agnostic DecideAsync seam.
|
||||
// Fail-closed on service errors: return 503 (not 500) so clients can retry.
|
||||
PolicyResult result;
|
||||
try
|
||||
{
|
||||
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext));
|
||||
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext), viewAs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -113,6 +117,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
if (result.Allowed)
|
||||
{
|
||||
context.Items[RequestContext.ItemsKey] = result.Context!;
|
||||
if (result.Context!.ViewAs != null)
|
||||
Telemetry.ViewAsTelemetryProcessor.Enter(result.Context.TenantId);
|
||||
_logger.LogDebug("[PolicyEnforcement] ALLOW {Method} {Path} user={User} role={Role}",
|
||||
logMethod, logPath, LogSanitizer.Clean(result.UserIdentifier), result.UserRole);
|
||||
await next(context);
|
||||
@@ -125,7 +131,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason} oid={Oid} tid={Tid} clientSource={ClientSource} mcpTool={McpTool}",
|
||||
logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason,
|
||||
LogSanitizer.Clean(principal?.GetObjectId()), LogSanitizer.Clean(principal?.GetTenantId()), clientSource, mcpToolName);
|
||||
await ReportPrivilegedDenialAsync(context, result, httpMethod, requestPath, principal, clientSource, mcpToolName);
|
||||
await ReportPrivilegedDenialAsync(context, result, httpMethod, requestPath, principal, clientSource, mcpToolName, viewAs != null);
|
||||
// Envelope: the human message is `error`, the policy code (Forbidden, CrossTenantAccessDenied,
|
||||
// TenantSuspended, McpDisabled, AuthenticationRequired, InsufficientPermissions) is `code`.
|
||||
await ApiErrorWriter.WriteAsync(
|
||||
@@ -152,9 +158,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// </summary>
|
||||
private async Task ReportPrivilegedDenialAsync(
|
||||
FunctionContext context, PolicyResult result, string httpMethod, string requestPath,
|
||||
ClaimsPrincipal? principal, string? clientSource, string? mcpToolName)
|
||||
ClaimsPrincipal? principal, string? clientSource, string? mcpToolName, bool viewAsRequested)
|
||||
{
|
||||
if (!IsPrivilegedDenial(result) || principal == null)
|
||||
if (!IsPrivilegedDenial(result, viewAsRequested) || principal == null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
@@ -169,11 +175,17 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A 403 by an authenticated caller on a GlobalAdminOnly route — the only denial that is reported.</summary>
|
||||
internal static bool IsPrivilegedDenial(PolicyResult result)
|
||||
/// <summary>
|
||||
/// A 403 by an authenticated caller on a GlobalAdminOnly route — the only denial that is reported. Under
|
||||
/// view-as the emulated member's denials are the expected picture and stay silent; the report is then the
|
||||
/// refused view-as itself (someone without Global Admin sending the header).
|
||||
/// </summary>
|
||||
internal static bool IsPrivilegedDenial(PolicyResult result, bool viewAsRequested = false)
|
||||
=> !result.Allowed
|
||||
&& result.Policy == EndpointPolicy.GlobalAdminOnly
|
||||
&& result.StatusCode == (int)HttpStatusCode.Forbidden;
|
||||
&& result.StatusCode == (int)HttpStatusCode.Forbidden
|
||||
&& (viewAsRequested
|
||||
? result.ErrorCode == Constants.ApiErrorCodes.ViewAsNotAllowed
|
||||
: result.Policy == EndpointPolicy.GlobalAdminOnly);
|
||||
|
||||
/// <summary>Longest request path kept in a denial record; the route catalog has nothing longer.</summary>
|
||||
private const int MaxDenialPathLength = 256;
|
||||
@@ -210,11 +222,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// is unit-testable without faking the worker's HttpContext feature. <see cref="Invoke"/> is a thin
|
||||
/// adapter that reads the request, calls this, and writes the response.
|
||||
/// <paramref name="isMcpClient"/> is the self-declared <c>X-Client-Source: mcp</c> marker: it only ever
|
||||
/// narrows (the tenant MCP switch), never grants.
|
||||
/// narrows (the tenant MCP switch), never grants. <paramref name="viewAs"/> (the view-as headers) only
|
||||
/// ever narrows too: a Global Admin is decided as the emulated member of the viewed tenant, read-only.
|
||||
/// </summary>
|
||||
internal async Task<PolicyResult> DecideAsync(
|
||||
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal,
|
||||
bool isMcpClient = false)
|
||||
bool isMcpClient = false, ViewAsTarget? viewAs = null)
|
||||
{
|
||||
// Sanitized copies for logging only — never used for routing/authorization decisions.
|
||||
var logPath = LogSanitizer.Clean(requestPath);
|
||||
@@ -229,9 +242,23 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "Forbidden", "Access denied.", "anonymous", "N/A", "Unregistered");
|
||||
}
|
||||
|
||||
var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry);
|
||||
|
||||
// View as tenant: admitted first, then decided by the same evaluators over an emulated member of the
|
||||
// viewed tenant — everything below runs in that tenant (cross-tenant check, suspension gate, context).
|
||||
// Routes without a JWT tier ignore the headers.
|
||||
var jwtTenantId = principal?.GetTenantId() ?? string.Empty;
|
||||
ViewAsTarget? viewing = null;
|
||||
if (viewAs != null && catalogEntry.Policy is not (EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth))
|
||||
{
|
||||
var refusal = await AdmitViewAsAsync(viewAs, principal, isMcpClient, catalogEntry);
|
||||
if (refusal != null)
|
||||
return refusal;
|
||||
viewing = viewAs;
|
||||
}
|
||||
var actingTenantId = viewing?.TenantId ?? jwtTenantId;
|
||||
var roles = viewing == null ? CallerRoles.Real(this, principal) : CallerRoles.Emulated(viewing.Role);
|
||||
|
||||
var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry, roles, actingTenantId);
|
||||
|
||||
var upn = principal?.GetUserPrincipalName();
|
||||
var isGlobalAdmin = decision.IsAllowed && decision.UserRole == Constants.GlobalRoles.GlobalAdmin;
|
||||
var isGlobalReader = decision.IsAllowed && decision.UserRole == Constants.GlobalRoles.GlobalReader;
|
||||
@@ -240,9 +267,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
// Resolve the target tenant for tenant-scoped routes ({tenantId} in path, or ?tenantId= query).
|
||||
// crossTenant = the route names a tenant OTHER than the caller's JWT tenant.
|
||||
var (targetTenantId, namedTarget) = ResolveTarget(catalogEntry, requestPath, queryTenantId, jwtTenantId);
|
||||
var (targetTenantId, namedTarget) = ResolveTarget(catalogEntry, requestPath, queryTenantId, actingTenantId);
|
||||
var crossTenant = namedTarget != null
|
||||
&& !string.Equals(namedTarget, jwtTenantId, StringComparison.OrdinalIgnoreCase);
|
||||
&& !string.Equals(namedTarget, actingTenantId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Delegated ("scoped global" / MSP) resolution. A delegated admin reaches a SUBSET of tenants —
|
||||
// ONLY on tenant-scoped read routes where the named target is in its scope. We resolve lazily:
|
||||
@@ -256,7 +283,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
// Full identity (upn + tid + oid): the scope resolves only for the bound identity, and the home
|
||||
// tenant (JWT tid) gates it — MSP seats require a Pro home tenant.
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(principal));
|
||||
var scope = await roles.DelegatedScopeAsync();
|
||||
delegatedRole = scope.RoleFor(namedTarget);
|
||||
if (delegatedRole != null)
|
||||
allowedTenantIds = scope.TenantIds;
|
||||
@@ -294,7 +321,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
if (crossTenant && !hasGlobalScope && !delegatedGrantsRead)
|
||||
{
|
||||
_logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access: user={User} jwtTenant={JwtTenant} target={Target} scoping={Scoping} path={Path}",
|
||||
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(jwtTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath);
|
||||
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(actingTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath);
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "CrossTenantAccessDenied",
|
||||
"Access denied. You can only access your own tenant's resources.",
|
||||
decision.UserIdentifier, decision.UserRole, "CrossTenant", catalogEntry.Policy);
|
||||
@@ -312,7 +339,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// farewell feedback arrives after their own tenant was tombstoned); any other suspension still holds.
|
||||
if (principal != null && !hasGlobalScope)
|
||||
{
|
||||
var gateConfigs = await LoadGateConfigsAsync(jwtTenantId, targetTenantId);
|
||||
var gateConfigs = await LoadGateConfigsAsync(actingTenantId, targetTenantId);
|
||||
var suspended = gateConfigs.FirstOrDefault(c => c.IsCurrentlyDisabled()
|
||||
&& !(catalogEntry.AllowedDuringOffboarding && TenantOffboardFunction.IsOffboardingTombstone(c)));
|
||||
if (suspended != null)
|
||||
@@ -360,8 +387,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
var requestContext = new RequestContext
|
||||
{
|
||||
TenantId = jwtTenantId,
|
||||
TenantId = actingTenantId,
|
||||
TargetTenantId = targetTenantId,
|
||||
ViewAs = viewing == null ? null : new ViewAsContext(jwtTenantId, viewing.Role),
|
||||
// ONLY a genuine JWT UPN lands here — never the "anonymous" placeholder that
|
||||
// decision.UserIdentifier carries on device/anonymous routes. The placeholder used to be
|
||||
// published here, which collapsed the entire agent fleet (every tenant) into one shared
|
||||
@@ -431,6 +459,40 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
return admitted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admission of a view-as request before any policy evaluation: usable headers, a Global Admin in person
|
||||
/// (an MCP call never; a foreign client app's capped identity holds no platform role), an onboarded
|
||||
/// tenant, and a route that only reads. Null = admitted.
|
||||
/// </summary>
|
||||
private async Task<PolicyResult?> AdmitViewAsAsync(
|
||||
ViewAsTarget viewAs, ClaimsPrincipal? principal, bool isMcpClient, EndpointPolicyEntry entry)
|
||||
{
|
||||
var user = principal?.GetUserPrincipalName() ?? "anonymous";
|
||||
if (principal == null)
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Unauthorized, Constants.ApiErrorCodes.AuthenticationRequired,
|
||||
"Authentication required. Please provide a valid JWT token.", user, "N/A", "NoJWT", entry.Policy);
|
||||
|
||||
if (!viewAs.IsValid)
|
||||
return PolicyResult.Deny((int)HttpStatusCode.BadRequest, Constants.ApiErrorCodes.ViewAsInvalid,
|
||||
"View as: the tenant must be a tenant ID and the role Admin, Operator or Viewer.", user, "N/A", "ViewAsInvalid", entry.Policy);
|
||||
|
||||
var globalRole = isMcpClient ? null : await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
|
||||
if (globalRole != Constants.GlobalRoles.GlobalAdmin)
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, Constants.ApiErrorCodes.ViewAsNotAllowed,
|
||||
"Viewing a tenant as its member is reserved for Global Admins.", user, globalRole ?? "N/A", "ViewAsNotAllowed", entry.Policy);
|
||||
|
||||
var (_, exists) = await _tenantConfigService.TryGetConfigurationAsync(viewAs.TenantId);
|
||||
if (!exists)
|
||||
return PolicyResult.Deny((int)HttpStatusCode.NotFound, Constants.ApiErrorCodes.ViewAsTenantNotFound,
|
||||
"View as: no onboarded tenant has this ID.", user, globalRole, "ViewAsTenantNotFound", entry.Policy);
|
||||
|
||||
if (entry.ViewAsAccess == ViewAsAccess.Write)
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, Constants.ApiErrorCodes.ViewAsReadOnly,
|
||||
ViewAsTarget.ReadOnlyMessage, user, globalRole, "ViewAsReadOnly", entry.Policy);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the request's target tenant. Returns the effective TargetTenantId (defaults to the JWT
|
||||
/// tenant) and the explicitly-named tenant from the route/query (null when the route does not name one,
|
||||
@@ -479,12 +541,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// as a delegated read tier so RoleFor(target) can rescue the cross-tenant block.
|
||||
or EndpointPolicy.GlobalReadOrDelegatedSubset;
|
||||
|
||||
private async Task<CatalogDecisionResult> EvaluateCatalogPolicyAsync(
|
||||
ClaimsPrincipal? principal, EndpointPolicyEntry entry)
|
||||
private static async Task<CatalogDecisionResult> EvaluateCatalogPolicyAsync(
|
||||
ClaimsPrincipal? principal, EndpointPolicyEntry entry, CallerRoles roles, string actingTenantId)
|
||||
{
|
||||
// principal is the authenticated ClaimsPrincipal (set by AuthenticationMiddleware), or null on
|
||||
// anonymous/device routes — resolved by the caller (Invoke / DecideAsync).
|
||||
var tenantId = principal?.GetTenantId();
|
||||
// anonymous/device routes — resolved by the caller (Invoke / DecideAsync). Every role below comes
|
||||
// from `roles`, the tenant from actingTenantId (the JWT tenant, or the viewed one under view-as).
|
||||
var tenantId = principal == null ? null : actingTenantId;
|
||||
var upn = principal?.GetUserPrincipalName();
|
||||
var userIdentifier = upn ?? "anonymous";
|
||||
|
||||
@@ -510,31 +573,31 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT");
|
||||
|
||||
case EndpointPolicy.AuthenticatedUserWithRole:
|
||||
return await EvaluateAuthenticatedUserWithRoleAsync(tenantId, upn, principal, userIdentifier);
|
||||
return await EvaluateAuthenticatedUserWithRoleAsync(tenantId, upn, principal, roles, userIdentifier);
|
||||
|
||||
case EndpointPolicy.MemberRead:
|
||||
return await EvaluateMemberReadAsync(tenantId, upn, principal, userIdentifier);
|
||||
return await EvaluateMemberReadAsync(tenantId, upn, roles, userIdentifier);
|
||||
|
||||
case EndpointPolicy.TenantAdminOrGlobalReader:
|
||||
return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, principal, userIdentifier);
|
||||
return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, roles, userIdentifier);
|
||||
|
||||
case EndpointPolicy.TenantAdminOrOperator:
|
||||
return await EvaluateTenantAdminOrOperatorAsync(tenantId, upn, principal, userIdentifier);
|
||||
return await EvaluateTenantAdminOrOperatorAsync(tenantId, upn, roles, userIdentifier);
|
||||
|
||||
case EndpointPolicy.TenantAdminOrGA:
|
||||
return await EvaluateTenantAdminOrGAAsync(tenantId, upn, principal, userIdentifier);
|
||||
return await EvaluateTenantAdminOrGAAsync(tenantId, upn, roles, userIdentifier);
|
||||
|
||||
case EndpointPolicy.BootstrapManagerOrGA:
|
||||
return await EvaluateBootstrapManagerOrGAAsync(tenantId, upn, principal, userIdentifier);
|
||||
return await EvaluateBootstrapManagerOrGAAsync(tenantId, upn, roles, userIdentifier);
|
||||
|
||||
case EndpointPolicy.GlobalReadOrAdmin:
|
||||
return await EvaluateGlobalReadOrAdminAsync(principal, upn, userIdentifier);
|
||||
return await EvaluateGlobalReadOrAdminAsync(upn, roles, userIdentifier);
|
||||
|
||||
case EndpointPolicy.GlobalReadOrDelegatedSubset:
|
||||
return await EvaluateGlobalReadOrDelegatedSubsetAsync(principal, upn, userIdentifier);
|
||||
return await EvaluateGlobalReadOrDelegatedSubsetAsync(upn, roles, userIdentifier);
|
||||
|
||||
case EndpointPolicy.GlobalAdminOnly:
|
||||
return await EvaluateGlobalAdminOnlyAsync(principal, upn, userIdentifier);
|
||||
return await EvaluateGlobalAdminOnlyAsync(upn, roles, userIdentifier);
|
||||
|
||||
default:
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", $"UnknownPolicy:{entry.Policy}");
|
||||
@@ -548,8 +611,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// with no special role is admitted as "Authenticated" (role flags stay false) — non-member end
|
||||
/// users are intentionally allowed here; the function gates the privileged resources.
|
||||
/// </summary>
|
||||
private async Task<CatalogDecisionResult> EvaluateAuthenticatedUserWithRoleAsync(
|
||||
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
|
||||
private static async Task<CatalogDecisionResult> EvaluateAuthenticatedUserWithRoleAsync(
|
||||
string? tenantId, string? upn, ClaimsPrincipal? principal, CallerRoles roles, string userIdentifier)
|
||||
{
|
||||
if (principal == null)
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT");
|
||||
@@ -561,11 +624,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// (admin notification group, admin-audience notifications).
|
||||
if (!string.IsNullOrEmpty(upn))
|
||||
{
|
||||
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
|
||||
var globalRole = await roles.GlobalRoleAsync();
|
||||
if (globalRole != null)
|
||||
{
|
||||
var tenantRole = !string.IsNullOrEmpty(tenantId)
|
||||
? (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role
|
||||
? (await roles.TenantRoleAsync(tenantId, upn))?.Role
|
||||
: null;
|
||||
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "ValidJWT+GlobalScope", tenantRole);
|
||||
}
|
||||
@@ -576,7 +639,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// the request is allowed at the middleware tier.
|
||||
if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(upn))
|
||||
{
|
||||
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
|
||||
var role = await roles.TenantRoleAsync(tenantId, upn);
|
||||
if (role?.Role != null)
|
||||
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "ValidJWT+Member", role.Role);
|
||||
}
|
||||
@@ -586,8 +649,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
return CatalogDecisionResult.Allow(userIdentifier, "Authenticated", "ValidJWT");
|
||||
}
|
||||
|
||||
private async Task<CatalogDecisionResult> EvaluateMemberReadAsync(
|
||||
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
|
||||
private static async Task<CatalogDecisionResult> EvaluateMemberReadAsync(
|
||||
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
|
||||
@@ -595,14 +658,14 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// Any platform role (GlobalAdmin or read-only GlobalReader) satisfies a member-read. ADDITIVE:
|
||||
// also resolve the own-tenant role and carry it separately so IsTenantAdmin stays correct for a
|
||||
// GlobalReader who is also their tenant's admin (drives admin-audience on /api/notifications).
|
||||
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
|
||||
var globalRole = await roles.GlobalRoleAsync();
|
||||
if (globalRole != null)
|
||||
{
|
||||
var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role;
|
||||
var tenantRole = (await roles.TenantRoleAsync(tenantId, upn))?.Role;
|
||||
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScopeBypass", tenantRole);
|
||||
}
|
||||
|
||||
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
|
||||
var role = await roles.TenantRoleAsync(tenantId, upn);
|
||||
if (role == null)
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "NonMember", "NotInTenant");
|
||||
|
||||
@@ -618,36 +681,36 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// carried, so a GlobalReader who is also their tenant's Admin keeps IsTenantAdmin=true (config GET
|
||||
/// then serves them the FULL config for their own tenant, redacting only the read-only-reader view).
|
||||
/// </summary>
|
||||
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrGlobalReaderAsync(
|
||||
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
|
||||
private static async Task<CatalogDecisionResult> EvaluateTenantAdminOrGlobalReaderAsync(
|
||||
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
|
||||
|
||||
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
|
||||
var globalRole = await roles.GlobalRoleAsync();
|
||||
if (globalRole != null)
|
||||
{
|
||||
var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role;
|
||||
var tenantRole = (await roles.TenantRoleAsync(tenantId, upn))?.Role;
|
||||
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScopeBypass", tenantRole);
|
||||
}
|
||||
|
||||
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
|
||||
var role = await roles.TenantRoleAsync(tenantId, upn);
|
||||
if (role?.Role == Constants.TenantRoles.Admin)
|
||||
return CatalogDecisionResult.Allow(userIdentifier, Constants.TenantRoles.Admin, "TenantAdmin", Constants.TenantRoles.Admin);
|
||||
|
||||
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOrGlobalScope");
|
||||
}
|
||||
|
||||
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrGAAsync(
|
||||
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
|
||||
private static async Task<CatalogDecisionResult> EvaluateTenantAdminOrGAAsync(
|
||||
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
|
||||
|
||||
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
|
||||
if (await roles.IsGlobalAdminAsync())
|
||||
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
|
||||
|
||||
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
|
||||
var role = await roles.TenantRoleAsync(tenantId, upn);
|
||||
if (role?.Role == Constants.TenantRoles.Admin)
|
||||
return CatalogDecisionResult.Allow(userIdentifier, Constants.TenantRoles.Admin, "TenantAdmin");
|
||||
|
||||
@@ -660,32 +723,32 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// serves multiple operation kinds can re-gate the admin-only ones on IsTenantAdmin.
|
||||
/// The read-only Global Reader is deliberately NOT admitted — this is a write tier.
|
||||
/// </summary>
|
||||
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrOperatorAsync(
|
||||
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
|
||||
private static async Task<CatalogDecisionResult> EvaluateTenantAdminOrOperatorAsync(
|
||||
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
|
||||
|
||||
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
|
||||
if (await roles.IsGlobalAdminAsync())
|
||||
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
|
||||
|
||||
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
|
||||
var role = await roles.TenantRoleAsync(tenantId, upn);
|
||||
if (role?.Role == Constants.TenantRoles.Admin || role?.Role == Constants.TenantRoles.Operator)
|
||||
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "TenantAdminOrOperator");
|
||||
|
||||
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOperatorOrGA");
|
||||
}
|
||||
|
||||
private async Task<CatalogDecisionResult> EvaluateBootstrapManagerOrGAAsync(
|
||||
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
|
||||
private static async Task<CatalogDecisionResult> EvaluateBootstrapManagerOrGAAsync(
|
||||
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
|
||||
|
||||
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
|
||||
if (await roles.IsGlobalAdminAsync())
|
||||
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
|
||||
|
||||
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
|
||||
var role = await roles.TenantRoleAsync(tenantId, upn);
|
||||
var canManageBootstrap = role != null &&
|
||||
(role.Role == Constants.TenantRoles.Admin ||
|
||||
(role.Role == Constants.TenantRoles.Operator && role.CanManageBootstrapTokens));
|
||||
@@ -697,26 +760,69 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effective tenant member role (table first, Entra app-role claim as the gated fallback) — see
|
||||
/// <see cref="TenantMemberRoleResolver"/>, which the MCP access check shares so "member" means the same
|
||||
/// thing on every path.
|
||||
/// The role lookups a policy decision reads. An ordinary request resolves them from the platform, member
|
||||
/// and delegation tables; a view-as request answers "no platform role, no delegated scope, member role R
|
||||
/// of the viewed tenant". The evaluators take their roles only from here, so one rule set decides both.
|
||||
/// </summary>
|
||||
private Task<MemberRoleInfo?> ResolveEffectiveRoleAsync(
|
||||
string tenantId, string upn, ClaimsPrincipal? principal)
|
||||
=> _memberRoleResolver.ResolveAsync(tenantId, upn, principal?.GetAppRoles(), principal?.IsClientCapped() ?? false);
|
||||
private sealed class CallerRoles
|
||||
{
|
||||
private readonly PolicyEnforcementMiddleware? _owner;
|
||||
private readonly ClaimsPrincipal? _principal;
|
||||
private readonly MemberRoleInfo? _emulated;
|
||||
|
||||
private CallerRoles(PolicyEnforcementMiddleware? owner, ClaimsPrincipal? principal, MemberRoleInfo? emulated)
|
||||
{
|
||||
_owner = owner;
|
||||
_principal = principal;
|
||||
_emulated = emulated;
|
||||
}
|
||||
|
||||
public static CallerRoles Real(PolicyEnforcementMiddleware owner, ClaimsPrincipal? principal)
|
||||
=> new(owner, principal, null);
|
||||
|
||||
/// <summary>A member with this tenant role and nothing else — no bootstrap-token right, no platform role.</summary>
|
||||
public static CallerRoles Emulated(string tenantRole)
|
||||
=> new(null, null, new MemberRoleInfo { Role = tenantRole, CanManageBootstrapTokens = false });
|
||||
|
||||
public Task<string?> GlobalRoleAsync()
|
||||
=> _emulated != null
|
||||
? Task.FromResult<string?>(null)
|
||||
: _owner!._globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(_principal));
|
||||
|
||||
public Task<bool> IsGlobalAdminAsync()
|
||||
=> _emulated != null
|
||||
? Task.FromResult(false)
|
||||
: _owner!._globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(_principal));
|
||||
|
||||
/// <summary>
|
||||
/// Effective tenant member role (table first, Entra app-role claim as the gated fallback) — see
|
||||
/// <see cref="TenantMemberRoleResolver"/>, which the MCP access check shares so "member" means the same
|
||||
/// thing on every path.
|
||||
/// </summary>
|
||||
public Task<MemberRoleInfo?> TenantRoleAsync(string tenantId, string upn)
|
||||
=> _emulated != null
|
||||
? Task.FromResult<MemberRoleInfo?>(_emulated)
|
||||
: _owner!._memberRoleResolver.ResolveAsync(tenantId, upn, _principal?.GetAppRoles(), _principal?.IsClientCapped() ?? false);
|
||||
|
||||
/// <summary>Full identity (upn + tid + oid): the scope resolves only for the bound identity.</summary>
|
||||
public Task<DelegatedScope> DelegatedScopeAsync()
|
||||
=> _emulated != null
|
||||
? Task.FromResult(DelegatedScope.Empty)
|
||||
: _owner!._delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(_principal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide cross-tenant READ: admits GlobalAdmin and the read-only GlobalReader. The
|
||||
/// resolved role string flows onto RequestContext so the function still distinguishes GA from
|
||||
/// Reader (e.g. for any in-function write gate).
|
||||
/// </summary>
|
||||
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrAdminAsync(
|
||||
ClaimsPrincipal? principal, string? upn, string userIdentifier)
|
||||
private static async Task<CatalogDecisionResult> EvaluateGlobalReadOrAdminAsync(
|
||||
string? upn, CallerRoles roles, string userIdentifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(upn))
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
|
||||
|
||||
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
|
||||
var globalRole = await roles.GlobalRoleAsync();
|
||||
if (globalRole != null)
|
||||
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
|
||||
|
||||
@@ -731,19 +837,19 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// response to that set. A pure delegated reader of nothing, or any non-global non-delegated caller, is
|
||||
/// denied. Used by aggregate endpoints whose body can be filtered per tenant (e.g. config/all).
|
||||
/// </summary>
|
||||
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrDelegatedSubsetAsync(
|
||||
ClaimsPrincipal? principal, string? upn, string userIdentifier)
|
||||
private static async Task<CatalogDecisionResult> EvaluateGlobalReadOrDelegatedSubsetAsync(
|
||||
string? upn, CallerRoles roles, string userIdentifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(upn))
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
|
||||
|
||||
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
|
||||
var globalRole = await roles.GlobalRoleAsync();
|
||||
if (globalRole != null)
|
||||
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
|
||||
|
||||
// Full identity (upn + tid + oid): the scope resolves only for the bound identity, and the home
|
||||
// tenant (JWT tid) gates it — MSP seats require a Pro home tenant.
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(principal));
|
||||
var scope = await roles.DelegatedScopeAsync();
|
||||
if (!scope.IsEmpty)
|
||||
return CatalogDecisionResult.Allow(
|
||||
userIdentifier, Constants.DelegatedRoles.DelegatedReader, "DelegatedSubset",
|
||||
@@ -752,13 +858,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "NonGlobal", "NotGlobalOrDelegated");
|
||||
}
|
||||
|
||||
private async Task<CatalogDecisionResult> EvaluateGlobalAdminOnlyAsync(
|
||||
ClaimsPrincipal? principal, string? upn, string userIdentifier)
|
||||
private static async Task<CatalogDecisionResult> EvaluateGlobalAdminOnlyAsync(
|
||||
string? upn, CallerRoles roles, string userIdentifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(upn))
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
|
||||
|
||||
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
|
||||
if (await roles.IsGlobalAdminAsync())
|
||||
return CatalogDecisionResult.Allow(userIdentifier, Constants.GlobalRoles.GlobalAdmin, "IsGA");
|
||||
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "NonGA", "NotGlobalAdmin");
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Middleware;
|
||||
|
||||
@@ -75,6 +76,26 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The caller dimensions of the request row. A view-as request counts at the real caller — home tenant
|
||||
/// (<paramref name="tenantId"/>, resolved from <see cref="RequestContextExtensions.IdentityTenantId"/>) and
|
||||
/// Global Admin — and names the viewed tenant separately: activity, growth and usage figures read this row.
|
||||
/// </summary>
|
||||
internal static void StampCaller(IDictionary<string, string> properties, RequestContext ctx, string? tenantId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
properties[RequestRowDimensions.TenantId] = tenantId;
|
||||
if (!string.IsNullOrEmpty(ctx.UserPrincipalName))
|
||||
properties[RequestRowDimensions.UserId] = ctx.UserPrincipalName;
|
||||
if (ctx.ViewAs != null)
|
||||
{
|
||||
properties[RequestRowDimensions.UserRole] = Constants.GlobalRoles.GlobalAdmin;
|
||||
properties[RequestRowDimensions.ViewAsTenantId] = ctx.TenantId;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(ctx.UserRole))
|
||||
properties[RequestRowDimensions.UserRole] = ctx.UserRole;
|
||||
}
|
||||
|
||||
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
|
||||
{
|
||||
var httpContext = context.GetHttpContext();
|
||||
@@ -136,18 +157,18 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
// See memory project_ingest_request_multiplication_429 for the full diagnosis.
|
||||
|
||||
// Business context from downstream middleware
|
||||
requestTelemetry.Properties["Source"] = "WorkerMiddleware";
|
||||
requestTelemetry.Properties["FunctionName"] = functionName;
|
||||
requestTelemetry.Properties["HttpMethod"] = method;
|
||||
requestTelemetry.Properties["HttpPath"] = httpContext.Request.Path.Value ?? "";
|
||||
requestTelemetry.Properties[RequestRowDimensions.Source] = RequestRowDimensions.WorkerMiddleware;
|
||||
requestTelemetry.Properties[RequestRowDimensions.FunctionName] = functionName;
|
||||
requestTelemetry.Properties[RequestRowDimensions.HttpMethod] = method;
|
||||
requestTelemetry.Properties[RequestRowDimensions.HttpPath] = httpContext.Request.Path.Value ?? "";
|
||||
|
||||
var clientSource = httpContext.Request.Headers["X-Client-Source"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(clientSource))
|
||||
requestTelemetry.Properties["ClientSource"] = clientSource;
|
||||
requestTelemetry.Properties[RequestRowDimensions.ClientSource] = clientSource;
|
||||
|
||||
var mcpToolName = httpContext.Request.Headers["X-MCP-Tool-Name"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(mcpToolName))
|
||||
requestTelemetry.Properties["McpToolName"] = mcpToolName;
|
||||
requestTelemetry.Properties[RequestRowDimensions.McpToolName] = mcpToolName;
|
||||
|
||||
// APPLICATION PRINCIPALS — the request-row dimension that lets KQL separate automation
|
||||
// (app-only tokens) from people: "app" plus the calling client id, nothing for a person.
|
||||
@@ -167,7 +188,7 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
requestTelemetry.Properties["ClientAppId"] = clientAppId;
|
||||
|
||||
if (context.Items.TryGetValue("CorrelationId", out var corrId) && corrId is string correlationId)
|
||||
requestTelemetry.Properties["CorrelationId"] = correlationId;
|
||||
requestTelemetry.Properties[RequestRowDimensions.CorrelationId] = correlationId;
|
||||
|
||||
// THROTTLE SURFACE — set by UserRateLimitMiddleware from the signed client-auth claim
|
||||
// (portal = public client, integration = confidential client / app-only). Next to the
|
||||
@@ -212,16 +233,11 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
var reqCtx = context.GetRequestContext();
|
||||
var tenantId = ResolveTenantId(
|
||||
reqCtx.TenantId,
|
||||
reqCtx.IdentityTenantId(),
|
||||
context.Items,
|
||||
httpContext.Request.Path.Value ?? string.Empty,
|
||||
httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault());
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
requestTelemetry.Properties["TenantId"] = tenantId;
|
||||
if (!string.IsNullOrEmpty(reqCtx.UserPrincipalName))
|
||||
requestTelemetry.Properties["UserId"] = reqCtx.UserPrincipalName;
|
||||
if (!string.IsNullOrEmpty(reqCtx.UserRole))
|
||||
requestTelemetry.Properties["UserRole"] = reqCtx.UserRole;
|
||||
StampCaller(requestTelemetry.Properties, reqCtx, tenantId);
|
||||
|
||||
if (caughtException != null)
|
||||
requestTelemetry.Properties["ExceptionType"] = caughtException.GetType().Name;
|
||||
|
||||
@@ -36,7 +36,7 @@ public class UserPresenceMiddleware : IFunctionsWorkerMiddleware
|
||||
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
|
||||
{
|
||||
var ctx = context.GetRequestContext();
|
||||
if (!string.IsNullOrEmpty(ctx.UserPrincipalName) && !string.IsNullOrEmpty(ctx.TenantId))
|
||||
if (Records(ctx))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -56,6 +56,13 @@ public class UserPresenceMiddleware : IFunctionsWorkerMiddleware
|
||||
await next(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the request records presence: a signed-in person acting in their own tenant. A Global Admin
|
||||
/// viewing a tenant as its member (read-only "view as") is not present in that tenant.
|
||||
/// </summary>
|
||||
internal static bool Records(RequestContext ctx)
|
||||
=> ctx.ViewAs == null && !string.IsNullOrEmpty(ctx.UserPrincipalName) && !string.IsNullOrEmpty(ctx.TenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true at most once per <see cref="ThrottleWindow"/> per user (per process), and stamps
|
||||
/// the write time when it does. Keeps the hot path from upserting on every single request.
|
||||
|
||||
@@ -30,6 +30,12 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
/// </summary>
|
||||
public const string ThrottleSurfaceItemKey = "ThrottleSurface";
|
||||
|
||||
/// <summary>Start of the Warning line every refused request writes; the MCP usage overview finds the refusals by it.</summary>
|
||||
internal const string ThrottledLogPrefix = "[UserRateLimit] THROTTLED";
|
||||
|
||||
private const string ThrottledLogTemplate = ThrottledLogPrefix
|
||||
+ " caller={Caller} surface={Surface} requests={Count}/{Max} retryAfter={RetryAfter}s";
|
||||
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
@@ -163,7 +169,7 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
: 60;
|
||||
|
||||
_logger.LogWarning(
|
||||
"[UserRateLimit] THROTTLED caller={Caller} surface={Surface} requests={Count}/{Max} retryAfter={RetryAfter}s",
|
||||
ThrottledLogTemplate,
|
||||
requestContext.CallerId, surface.ToDimension(), result.RequestsInWindow, result.MaxRequests, retryAfterSeconds);
|
||||
|
||||
// Envelope with the retry window; the counters ride in the X-RateLimit-* headers above.
|
||||
|
||||
@@ -250,6 +250,7 @@ builder.Services.AddSingleton<AdminConfigurationService>();
|
||||
builder.Services.AddSingleton<AppHomingService>();
|
||||
builder.Services.AddSingleton<TenantEntitlementService>();
|
||||
builder.Services.AddSingleton<McpQuotaService>();
|
||||
builder.Services.AddSingleton<McpUsageOverviewService>();
|
||||
builder.Services.AddSingleton<ILatestVersionsService, LatestVersionsService>();
|
||||
builder.Services.AddSingleton<RateLimitService>();
|
||||
builder.Services.AddSingleton<DistressRateLimitService>();
|
||||
@@ -316,11 +317,12 @@ builder.Services.AddSingleton<OpsAlertDispatchService>();
|
||||
builder.Services.AddSingleton<OpsEventService>();
|
||||
// Assume-breach observer on the policy middleware's deny path (GlobalAdminOnly 403 → Critical ops event).
|
||||
builder.Services.AddSingleton<IPrivilegedDenialReporter, PrivilegedDenialReporter>();
|
||||
// Operator KQL proxy (global/raw/logs): the three telemetry stores it can reach, the managed-identity
|
||||
// credential as an injectable seam, and a client WITHOUT its own timeout — the per-request budget
|
||||
// (CancellationTokenSource + Prefer: wait) is the only clock.
|
||||
// Operator KQL proxy (global/raw/logs) and the fixed queries of the MCP usage overview: the three telemetry
|
||||
// stores, the managed-identity credential as an injectable seam, one executor for both, and a client WITHOUT
|
||||
// its own timeout — the per-request budget (CancellationTokenSource + Prefer: wait) is the only clock.
|
||||
builder.Services.AddSingleton<LogQuerySourceCatalog>();
|
||||
builder.Services.AddSingleton<Azure.Core.TokenCredential>(_ => new Azure.Identity.DefaultAzureCredential());
|
||||
builder.Services.AddSingleton<LogQueryExecutor>();
|
||||
builder.Services.AddHttpClient(LogQuerySourceCatalog.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan);
|
||||
// IME pattern-drift loop: folds ime_pattern_hits into ImePatternStats and raises
|
||||
// ImePatternDriftSuspected; singleton because it caches the stats snapshot for evaluation.
|
||||
@@ -547,6 +549,8 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.IPushSess
|
||||
// Push endpoints are per-device send capabilities: the dependency rows for the push services
|
||||
// keep scheme + host only (K20).
|
||||
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.PushDependencyRedactionProcessor>();
|
||||
// Items a Global Admin's read-only "view as" request emits carry the viewed tenant as ViewAsTenantId.
|
||||
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.ViewAsTelemetryProcessor>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
@@ -185,27 +185,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
var devices = data?["value"] as JArray;
|
||||
if (devices == null || devices.Count == 0)
|
||||
{
|
||||
// Definitive: device not found — cache negative result
|
||||
return CacheAndReturn(cacheKey, new AutopilotDeviceValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
SerialNumber = normalizedSerial,
|
||||
ErrorMessage = $"Device with serial '{normalizedSerial}' is not registered in Autopilot"
|
||||
}, isPositive: false);
|
||||
}
|
||||
|
||||
// Exact-match guard to avoid false positives from contains(...)
|
||||
var exactDevice = devices
|
||||
.FirstOrDefault(d => string.Equals(
|
||||
d?["serialNumber"]?.ToString()?.Trim(),
|
||||
normalizedSerial,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (exactDevice == null)
|
||||
var registration = FindRegistration(responseBody, normalizedSerial);
|
||||
if (registration == null)
|
||||
{
|
||||
// Definitive: device not found — cache negative result
|
||||
return CacheAndReturn(cacheKey, new AutopilotDeviceValidationResult
|
||||
@@ -220,7 +201,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
IsValid = true,
|
||||
SerialNumber = normalizedSerial,
|
||||
Registration = ReadRegistration(exactDevice)
|
||||
Registration = registration
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -272,6 +253,25 @@ namespace AutopilotMonitor.Functions.Security
|
||||
return $"autopilot-device-validation:{tenantId}:{serialNumber}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a windowsAutopilotDeviceIdentities list: the registration of the device whose serial
|
||||
/// equals <paramref name="normalizedSerial"/> exactly (the $filter only narrows with contains),
|
||||
/// or null when there is none. A body that is not a JSON object, or a non-object entry met
|
||||
/// before the match, throws; the caller treats that as transient.
|
||||
/// </summary>
|
||||
internal static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial)
|
||||
{
|
||||
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } data)
|
||||
return null;
|
||||
|
||||
var exactDevice = UpstreamJson.Items(data, "value")
|
||||
.FirstOrDefault(d => string.Equals(
|
||||
UpstreamJson.GetString(d, "serialNumber")?.Trim(),
|
||||
normalizedSerial,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
return exactDevice.ValueKind == JsonValueKind.Undefined ? null : ReadRegistration(exactDevice);
|
||||
}
|
||||
|
||||
/// <summary>Longest registration text a session keeps; anything beyond is cut.</summary>
|
||||
internal const int MaxRegistrationFieldLength = 256;
|
||||
|
||||
@@ -280,18 +280,18 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// assigned user's UPN is only tested for presence and never leaves this method; productKey is
|
||||
/// never read.
|
||||
/// </summary>
|
||||
internal static AutopilotRegistration ReadRegistration(JToken device) => new AutopilotRegistration
|
||||
internal static AutopilotRegistration ReadRegistration(JsonElement device) => new AutopilotRegistration
|
||||
{
|
||||
DeviceId = RegistrationText(device["id"]),
|
||||
GroupTag = RegistrationText(device["groupTag"]),
|
||||
PurchaseOrderId = RegistrationText(device["purchaseOrderIdentifier"]),
|
||||
DisplayName = RegistrationText(device["displayName"]),
|
||||
UserAssigned = RegistrationText(device["userPrincipalName"]) != null,
|
||||
DeviceId = RegistrationText(device, "id"),
|
||||
GroupTag = RegistrationText(device, "groupTag"),
|
||||
PurchaseOrderId = RegistrationText(device, "purchaseOrderIdentifier"),
|
||||
DisplayName = RegistrationText(device, "displayName"),
|
||||
UserAssigned = RegistrationText(device, "userPrincipalName") != null,
|
||||
};
|
||||
|
||||
private static string? RegistrationText(JToken? token)
|
||||
private static string? RegistrationText(JsonElement device, string name)
|
||||
{
|
||||
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
|
||||
var value = UpstreamJson.GetString(device, name)?.Trim();
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return null;
|
||||
return value!.Length > MaxRegistrationFieldLength ? value.Substring(0, MaxRegistrationFieldLength) : value;
|
||||
|
||||
@@ -6,8 +6,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
@@ -245,35 +245,34 @@ namespace AutopilotMonitor.Functions.Security
|
||||
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
|
||||
};
|
||||
|
||||
JObject? data;
|
||||
JsonElement? data;
|
||||
try
|
||||
{
|
||||
data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
data = UpstreamJson.ParseObjectOrNull(responseBody);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var cloudPcs = data?["value"] as JArray;
|
||||
if (cloudPcs == null || cloudPcs.Count == 0)
|
||||
if (data is not { } json)
|
||||
return notFound;
|
||||
|
||||
var match = cloudPcs.FirstOrDefault(c => string.Equals(
|
||||
c?["managedDeviceId"]?.ToString()?.Trim(),
|
||||
var match = UpstreamJson.Items(json, "value").FirstOrDefault(c => string.Equals(
|
||||
UpstreamJson.GetString(c, "managedDeviceId")?.Trim(),
|
||||
normalizedId,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match == null)
|
||||
if (match.ValueKind == JsonValueKind.Undefined)
|
||||
return notFound;
|
||||
|
||||
return new CloudPcValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
IntuneDeviceId = normalizedId,
|
||||
CloudPcId = match["id"]?.ToString(),
|
||||
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
|
||||
ServicePlanName = match["servicePlanName"]?.ToString()
|
||||
CloudPcId = UpstreamJson.GetString(match, "id"),
|
||||
ManagedDeviceName = UpstreamJson.GetString(match, "managedDeviceName"),
|
||||
ServicePlanName = UpstreamJson.GetString(match, "servicePlanName")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
@@ -301,24 +300,11 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
var identities = data?["value"] as JArray;
|
||||
if (identities != null)
|
||||
{
|
||||
foreach (var identity in identities)
|
||||
{
|
||||
var type = identity?["importedDeviceIdentityType"]?.ToString();
|
||||
var value = identity?["importedDeviceIdentifier"]?.ToString();
|
||||
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
|
||||
&& value != null
|
||||
&& string.Equals(NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
|
||||
{
|
||||
return new ScanResult(ScanOutcome.Found);
|
||||
}
|
||||
}
|
||||
}
|
||||
var (found, nextUrl) = ReadIdentityPage(responseBody, normalizedIdentifier);
|
||||
if (found)
|
||||
return new ScanResult(ScanOutcome.Found);
|
||||
|
||||
url = data?["@odata.nextLink"]?.ToString() ?? string.Empty;
|
||||
url = nextUrl;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
@@ -334,6 +320,32 @@ namespace AutopilotMonitor.Functions.Security
|
||||
return new ScanResult(ScanOutcome.NotFound);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one importedDeviceIdentities page: whether a manufacturerModelSerial identity on it
|
||||
/// normalizes to <paramref name="normalizedIdentifier"/>, and otherwise the next page's url
|
||||
/// (empty when the scan ends). A body that is not a JSON object, or a non-object entry met
|
||||
/// before the match, throws; the caller treats that as transient.
|
||||
/// </summary>
|
||||
internal static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier)
|
||||
{
|
||||
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } data)
|
||||
return (false, string.Empty);
|
||||
|
||||
foreach (var identity in UpstreamJson.Items(data, "value"))
|
||||
{
|
||||
var type = UpstreamJson.GetString(identity, "importedDeviceIdentityType");
|
||||
var value = UpstreamJson.GetString(identity, "importedDeviceIdentifier");
|
||||
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
|
||||
&& value != null
|
||||
&& string.Equals(NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
|
||||
{
|
||||
return (true, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
return (false, UpstreamJson.GetNextLink(data) ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes one identifier component the way the Intune portal does on upload:
|
||||
/// uppercase, every non-alphanumeric character removed ("Microsoft Corporation" →
|
||||
|
||||
@@ -7,8 +7,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
@@ -236,56 +236,42 @@ namespace AutopilotMonitor.Functions.Security
|
||||
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
|
||||
};
|
||||
|
||||
JObject? data;
|
||||
JsonElement? data;
|
||||
try
|
||||
{
|
||||
data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
data = UpstreamJson.ParseObjectOrNull(responseBody);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var devices = data?["value"] as JArray;
|
||||
if (devices == null || devices.Count == 0)
|
||||
if (data is not { } json)
|
||||
return notFound;
|
||||
|
||||
var match = devices.FirstOrDefault(d => string.Equals(
|
||||
d?["serialNumber"]?.ToString()?.Trim(),
|
||||
var match = UpstreamJson.Items(json, "value").FirstOrDefault(d => string.Equals(
|
||||
UpstreamJson.GetString(d, "serialNumber")?.Trim(),
|
||||
normalizedSerial,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match == null)
|
||||
if (match.ValueKind == JsonValueKind.Undefined)
|
||||
return notFound;
|
||||
|
||||
return new DeviceAssociationResult
|
||||
{
|
||||
IsValid = true,
|
||||
SerialNumber = normalizedSerial,
|
||||
AssociationState = match["associationState"]?.ToString(),
|
||||
DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(),
|
||||
PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(),
|
||||
AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(),
|
||||
PreAssociationDateTime = ParseDateTime(match["preassociationDateTime"]),
|
||||
AssociationDateTime = ParseDateTime(match["associationDateTime"]),
|
||||
ManagedDeviceId = match["managedDeviceId"]?.ToString()
|
||||
AssociationState = UpstreamJson.GetString(match, "associationState"),
|
||||
DevicePreparationPolicyId = UpstreamJson.GetString(match, "devicePreparationPolicyId"),
|
||||
PreAssociatedByUserPrincipalName = UpstreamJson.GetString(match, "preassociatedByUserPrincipalName"),
|
||||
AssignedToUserPrincipalName = UpstreamJson.GetString(match, "assignedToUserPrincipalName"),
|
||||
// Graph returns "0001-01-01T00:00:00Z" for unset dates; GetUtcDateTime reads it as null.
|
||||
PreAssociationDateTime = UpstreamJson.GetUtcDateTime(match, "preassociationDateTime")?.UtcDateTime,
|
||||
AssociationDateTime = UpstreamJson.GetUtcDateTime(match, "associationDateTime")?.UtcDateTime,
|
||||
ManagedDeviceId = UpstreamJson.GetString(match, "managedDeviceId")
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime? ParseDateTime(JToken? token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null) return null;
|
||||
var s = token.ToString();
|
||||
if (string.IsNullOrEmpty(s)) return null;
|
||||
// Graph returns "0001-01-01T00:00:00Z" for unset DateTimeOffset fields — treat as null.
|
||||
if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
|
||||
{
|
||||
if (dt.Year <= 1) return null;
|
||||
return dt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string BuildCacheKey(string tenantId, string serialNumber)
|
||||
=> $"device-association:{tenantId}:{serialNumber}";
|
||||
|
||||
|
||||
@@ -107,6 +107,18 @@ public enum EndpointPolicy
|
||||
GlobalAdminOnly,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a route may run while a Global Admin views a tenant as its member (read-only "view as").
|
||||
/// </summary>
|
||||
public enum ViewAsAccess
|
||||
{
|
||||
/// <summary>Reads only (or writes nothing but the caller's own per-user marker); runs in view-as mode.</summary>
|
||||
Read,
|
||||
|
||||
/// <summary>Changes state or starts an action; refused in view-as mode with ViewAsReadOnly.</summary>
|
||||
Write,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single entry in the endpoint access policy catalog.
|
||||
/// </summary>
|
||||
@@ -150,12 +162,19 @@ public sealed class EndpointPolicyEntry
|
||||
/// </summary>
|
||||
public bool AllowedDuringOffboarding { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Read or write for view-as mode. Defaults from the method — GET reads, everything else writes, so an
|
||||
/// unclassified route fails closed. Overridden only with a reason (pinned by ViewAsAccessCatalogTests):
|
||||
/// a POST that reads, or a GET that starts an action.
|
||||
/// </summary>
|
||||
public ViewAsAccess ViewAsAccess { get; }
|
||||
|
||||
// Pre-compiled regex for matching actual request paths against the route template
|
||||
internal Regex RouteRegex { get; }
|
||||
|
||||
public EndpointPolicyEntry(string httpMethod, string routeTemplate, EndpointPolicy policy,
|
||||
TenantScoping tenantScoping = TenantScoping.None, bool excludeDelegated = false, bool applicationAllowed = false,
|
||||
bool allowedDuringOffboarding = false)
|
||||
bool allowedDuringOffboarding = false, ViewAsAccess? viewAsAccess = null)
|
||||
{
|
||||
HttpMethod = httpMethod.ToUpperInvariant();
|
||||
RouteTemplate = routeTemplate;
|
||||
@@ -164,6 +183,7 @@ public sealed class EndpointPolicyEntry
|
||||
ExcludeDelegated = excludeDelegated;
|
||||
ApplicationAllowed = applicationAllowed;
|
||||
AllowedDuringOffboarding = allowedDuringOffboarding;
|
||||
ViewAsAccess = viewAsAccess ?? (HttpMethod == "GET" ? ViewAsAccess.Read : ViewAsAccess.Write);
|
||||
RouteRegex = BuildRouteRegex(routeTemplate);
|
||||
}
|
||||
|
||||
@@ -236,12 +256,13 @@ public static class EndpointAccessPolicyCatalog
|
||||
|
||||
// ── AuthenticatedUser ───────────────────────────────────────────
|
||||
new("GET", "auth/me", EndpointPolicy.AuthenticatedUser),
|
||||
new("PUT", "auth/me/whats-new-seen", EndpointPolicy.AuthenticatedUser),
|
||||
// The caller's own per-user marker under the JWT tenant — also in view-as mode, where What's new opens with it.
|
||||
new("PUT", "auth/me/whats-new-seen", EndpointPolicy.AuthenticatedUser, viewAsAccess: ViewAsAccess.Read),
|
||||
// Terms + DPA acceptance: writes only the caller's own home tenant row (token tid), and only
|
||||
// while that row asks for it — the portal dialog of a tenant onboarded without the signup tick.
|
||||
new("POST", "auth/dpa-acceptance", EndpointPolicy.AuthenticatedUser),
|
||||
new("GET", "auth/is-global-admin", EndpointPolicy.AuthenticatedUser),
|
||||
new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser),
|
||||
new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser, viewAsAccess: ViewAsAccess.Read),
|
||||
// Group join/leave is AuthenticatedUserWithRole — NOT MemberRead — on purpose. The real,
|
||||
// per-group authorization lives inside SignalRAddToGroup/RemoveFromGroupFunction: it
|
||||
// requires the requested group's tenant to match the caller's JWT tenant (platform-scope
|
||||
@@ -260,8 +281,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
// still resolves IsGlobalAdmin/IsTenantAdmin/UserRole — the function's per-group gates
|
||||
// depend on those flags; plain AuthenticatedUser leaves them always-false and would break
|
||||
// GA/admin group joins. (Regression history: c4dabeee elevated these to MemberRead.)
|
||||
new("POST", "realtime/groups/join", EndpointPolicy.AuthenticatedUserWithRole),
|
||||
new("POST", "realtime/groups/leave", EndpointPolicy.AuthenticatedUserWithRole),
|
||||
new("POST", "realtime/groups/join", EndpointPolicy.AuthenticatedUserWithRole, viewAsAccess: ViewAsAccess.Read),
|
||||
new("POST", "realtime/groups/leave", EndpointPolicy.AuthenticatedUserWithRole, viewAsAccess: ViewAsAccess.Read),
|
||||
// Progress Portal authorization model: serial-number knowledge proof, enforced in-function.
|
||||
// There is deliberately NO tenant-wide session list at this tier — the lookup resolves at
|
||||
// most ONE session server-side (roleless callers: exact serial/device-name match; members/GA
|
||||
@@ -351,7 +372,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "audit/device-not-registered", EndpointPolicy.MemberRead),
|
||||
new("GET", "audit/tpm-pss-unsupported", EndpointPolicy.MemberRead),
|
||||
new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam, viewAsAccess: ViewAsAccess.Read),
|
||||
// Platform-defined diagnostics collection (built-in catalog + global paths): read-only,
|
||||
// no secrets, needed by every settings viewer — member tier, JWT-scoped (no tenant parameter).
|
||||
new("GET", "diagnostics/paths", EndpointPolicy.MemberRead, TenantScoping.Jwt),
|
||||
@@ -362,10 +383,10 @@ public static class EndpointAccessPolicyCatalog
|
||||
// against a session's events and persists NOTHING (DryRunAnalyzeRuleFunction). Admin-read
|
||||
// tier so the read-only Global Reader can help debug rules while tenant Operators/Viewers
|
||||
// stay excluded from the authoring surface.
|
||||
new("POST", "rules/analyze/dryrun", EndpointPolicy.TenantAdminOrGlobalReader),
|
||||
new("POST", "rules/analyze/dryrun", EndpointPolicy.TenantAdminOrGlobalReader, viewAsAccess: ViewAsAccess.Read),
|
||||
// Same shape: POST body in, pure compute out (regex vs pasted sample lines with the
|
||||
// agent's exact .NET matching semantics) — touches no tenant data at all.
|
||||
new("POST", "rules/gather/test-pattern", EndpointPolicy.TenantAdminOrGlobalReader),
|
||||
new("POST", "rules/gather/test-pattern", EndpointPolicy.TenantAdminOrGlobalReader, viewAsAccess: ViewAsAccess.Read),
|
||||
// Member tier so Operators/Viewers can see their tenant's configuration read-only in the
|
||||
// Settings UI. Safe because the handler redacts by default: only a Global Admin or the
|
||||
// tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator,
|
||||
@@ -418,7 +439,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
// Farewell feedback is submitted AFTER the offboard endpoint has tombstoned the tenant (Disabled=true
|
||||
// before the 202), so the suspension gate must let the departing admin through — for this route only.
|
||||
new("POST", "tenants/{tenantId}/offboard/feedback", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, allowedDuringOffboarding: true),
|
||||
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
// Starts the admin-consent flow (ops events with the caller's UPN): an action, although a GET.
|
||||
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, viewAsAccess: ViewAsAccess.Write),
|
||||
new("GET", "config/{tenantId}/autopilot-device-validation/consent-status", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/autopilot-device-validation/consent-failure", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/autopilot-device-validation/consent-success", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
@@ -438,9 +460,10 @@ public static class EndpointAccessPolicyCatalog
|
||||
// expose ClientId and invalidate caches.
|
||||
// POST (not GET) so payloads with 100+ script refs comfortably fit in the request
|
||||
// body without hitting URL-length limits in browsers / Azure Functions front door.
|
||||
new("POST", "tenants/{tenantId}/scripts/display-names", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
|
||||
new("POST", "tenants/{tenantId}/scripts/display-names", EndpointPolicy.MemberRead, TenantScoping.RouteParam, viewAsAccess: ViewAsAccess.Read),
|
||||
new("GET", "tenants/{tenantId}/graph-permissions/status", EndpointPolicy.TenantAdminOrGlobalReader, TenantScoping.RouteParam),
|
||||
new("POST", "tenants/{tenantId}/graph-permissions/refresh", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
// Clears the in-memory Graph cache only — a re-read, nothing stored.
|
||||
new("POST", "tenants/{tenantId}/graph-permissions/refresh", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, viewAsAccess: ViewAsAccess.Read),
|
||||
|
||||
// ── BootstrapManagerOrGA ────────────────────────────────────────
|
||||
new("GET", "bootstrap/sessions", EndpointPolicy.BootstrapManagerOrGA, TenantScoping.QueryParam),
|
||||
@@ -575,12 +598,11 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "global/metrics/sla", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
// A named tenant's MCP use — its accounts (UPNs) and their tools, the daily counts, the organization view.
|
||||
// The handlers read ?tenantId= themselves: deliberately None, not QueryParam, so the delegated path never
|
||||
// reaches them.
|
||||
new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin),
|
||||
// MCP use across tenants: a named tenant's organization budget by account (UPNs) and the all-tenant overview
|
||||
// with every tenant's accounts and tools. The organization handler reads ?tenantId= itself: deliberately None,
|
||||
// not QueryParam, so the delegated path never reaches either.
|
||||
new("GET", "global/metrics/mcp-usage/organization", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/metrics/mcp-usage/overview", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin),
|
||||
// The operator's report queue: GA-only AdminNote, the submitter's comment and contact data. ?tenantId= is a
|
||||
// filter the handler reads itself; None, so no delegated reader reaches it.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
@@ -39,16 +39,15 @@ namespace AutopilotMonitor.Functions.Security
|
||||
public static EntraTokenRejection Parse(int statusCode, string? body)
|
||||
{
|
||||
var json = TryParseObject(body);
|
||||
var description = (json?["error_description"] as JValue)?.Value as string;
|
||||
var description = json is { } root ? UpstreamJson.GetString(root, "error_description") : null;
|
||||
return new EntraTokenRejection(statusCode, ReadCode(json, description), Shorten(description));
|
||||
}
|
||||
|
||||
private static JObject? TryParseObject(string? body)
|
||||
private static JsonElement? TryParseObject(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body)) return null;
|
||||
try
|
||||
{
|
||||
return JsonConvert.DeserializeObject<JToken>(body) as JObject;
|
||||
return UpstreamJson.ParseObjectOrNull(body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -56,10 +55,17 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadCode(JObject? json, string? description)
|
||||
private static string? ReadCode(JsonElement? json, string? description)
|
||||
{
|
||||
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
|
||||
if (first != null) return CodePrefix + first.Value<long>();
|
||||
if (json is { } root)
|
||||
{
|
||||
// The first whole number that fits Int64; text, fractions and larger numbers are skipped.
|
||||
foreach (var code in UpstreamJson.Items(root, "error_codes"))
|
||||
{
|
||||
if (code.ValueKind == JsonValueKind.Number && code.TryGetInt64(out var value))
|
||||
return CodePrefix + value;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for a body without error_codes: the description starts with the code.
|
||||
var start = description?.IndexOf(CodePrefix, StringComparison.Ordinal) ?? -1;
|
||||
|
||||
@@ -9,8 +9,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
@@ -304,13 +303,12 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
var accessToken = tokenJson?["access_token"]?.ToString();
|
||||
var (accessToken, expiresInSeconds) = ReadTokenResponse(responseBody);
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
// Cache the freshly minted token (never log its value). TTL derives from
|
||||
// the token's own expires_in, minus a skew margin, clamped to a safe band.
|
||||
CacheToken(tenantId, clientId, accessToken!, genAtStart, (long?)tokenJson?["expires_in"]);
|
||||
CacheToken(tenantId, clientId, accessToken!, genAtStart, expiresInSeconds);
|
||||
}
|
||||
return GraphTokenResult.Minted(accessToken);
|
||||
}
|
||||
@@ -468,12 +466,26 @@ namespace AutopilotMonitor.Functions.Security
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a successful token response: the access token, and expires_in only when a token came
|
||||
/// back. A body that is not a JSON object throws <see cref="System.Text.Json.JsonException"/>, which the
|
||||
/// retry loop does not catch: it leaves the token service like any other unexpected failure.
|
||||
/// </summary>
|
||||
internal static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody)
|
||||
{
|
||||
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } tokenJson)
|
||||
return (null, null);
|
||||
var accessToken = UpstreamJson.GetString(tokenJson, "access_token");
|
||||
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, UpstreamJson.GetInt64(tokenJson, "expires_in"));
|
||||
}
|
||||
|
||||
private void CacheToken(string tenantId, string clientId, string accessToken, long genAtStart, long? expiresInSeconds)
|
||||
{
|
||||
// expires_in is reported by AAD over TLS, so it is trustworthy — but still clamped so a
|
||||
// missing or pathological value cannot pin a stale token (cap) or cause thrash (floor).
|
||||
// The seconds are capped before the conversion: a TimeSpan cannot hold the far end of a long.
|
||||
var lifetime = expiresInSeconds is > 0
|
||||
? TimeSpan.FromSeconds(expiresInSeconds.Value)
|
||||
? TimeSpan.FromSeconds(Math.Min(expiresInSeconds.Value, (long)(MaxTokenTtl + TokenSkewMargin).TotalSeconds))
|
||||
: DefaultTokenLifetime;
|
||||
var ttl = lifetime - TokenSkewMargin;
|
||||
if (ttl < MinTokenTtl) ttl = MinTokenTtl;
|
||||
|
||||
@@ -8,8 +8,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
@@ -401,40 +401,42 @@ namespace AutopilotMonitor.Functions.Security
|
||||
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
|
||||
};
|
||||
|
||||
JObject? device;
|
||||
JsonElement? parsed;
|
||||
try
|
||||
{
|
||||
device = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
parsed = UpstreamJson.ParseObjectOrNull(responseBody);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var returnedId = device?["id"]?.ToString()?.Trim();
|
||||
if (parsed is not { } device)
|
||||
return notFound;
|
||||
|
||||
var returnedId = UpstreamJson.GetString(device, "id")?.Trim();
|
||||
if (string.IsNullOrEmpty(returnedId) ||
|
||||
!string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var managementState = device?["managementState"]?.ToString();
|
||||
var managementState = UpstreamJson.GetString(device, "managementState");
|
||||
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
|
||||
|
||||
return new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
|
||||
IntuneDeviceId = normalizedId,
|
||||
DeviceName = device?["deviceName"]?.ToString(),
|
||||
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
|
||||
DeviceName = UpstreamJson.GetString(device, "deviceName"),
|
||||
AzureAdDeviceId = UpstreamJson.GetString(device, "azureADDeviceId"),
|
||||
ManagementState = managementState,
|
||||
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
|
||||
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
|
||||
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
|
||||
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
|
||||
? device["azureADRegistered"]!.Value<bool>()
|
||||
: null,
|
||||
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
|
||||
SerialNumber = NullIfEmpty(UpstreamJson.GetString(device, "serialNumber")),
|
||||
OwnerType = NullIfEmpty(UpstreamJson.GetString(device, "managedDeviceOwnerType")),
|
||||
EnrollmentType = NullIfEmpty(UpstreamJson.GetString(device, "deviceEnrollmentType")),
|
||||
AzureAdRegistered = UpstreamJson.GetBoolean(device, "azureADRegistered"),
|
||||
// Intune returns "0001-01-01T00:00:00Z" when it has no enrollment timestamp: absence, not a date.
|
||||
EnrolledDateTime = UpstreamJson.GetUtcDateTime(device, "enrolledDateTime"),
|
||||
ErrorMessage = leaving
|
||||
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
|
||||
: null
|
||||
@@ -448,28 +450,6 @@ namespace AutopilotMonitor.Functions.Security
|
||||
internal static bool IsRecentEnrollment(DateTimeOffset? certNotBefore, DateTimeOffset now)
|
||||
=> certNotBefore.HasValue && now - certNotBefore.Value < RecentEnrollmentWindow;
|
||||
|
||||
/// <summary>
|
||||
/// Parses Graph's <c>enrolledDateTime</c>. Intune returns <c>0001-01-01T00:00:00Z</c> for
|
||||
/// devices it has no enrollment timestamp for; that is absence, not a date, so it maps to
|
||||
/// <c>null</c> rather than a bogus age of two thousand years.
|
||||
/// </summary>
|
||||
internal static DateTimeOffset? ParseEnrolledDateTime(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
|
||||
if (!DateTimeOffset.TryParse(
|
||||
raw,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal
|
||||
| System.Globalization.DateTimeStyles.AssumeUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.Year <= 1 ? null : parsed;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
internal static string BuildCacheKey(string tenantId, string intuneDeviceId)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security;
|
||||
|
||||
/// <summary>
|
||||
/// "View as tenant": a Global Admin reads one tenant's portal exactly as a member of that tenant sees it,
|
||||
/// read-only. The portal tab sends these headers on every API call; <c>PolicyEnforcementMiddleware</c>
|
||||
/// admits the request only for a Global Admin, refuses every write, and builds the request context of the
|
||||
/// emulated member.
|
||||
/// </summary>
|
||||
public static class ViewAsHeaders
|
||||
{
|
||||
public const string Tenant = "X-View-As-Tenant";
|
||||
public const string Role = "X-View-As-Role";
|
||||
}
|
||||
|
||||
/// <summary>A parsed view-as request: the tenant to view (lowercase GUID) and the tenant role to emulate.</summary>
|
||||
public sealed record ViewAsTarget(string TenantId, string Role)
|
||||
{
|
||||
/// <summary>Headers that are present but unusable: not a GUID, or not one of the three tenant roles.</summary>
|
||||
public static readonly ViewAsTarget Invalid = new(string.Empty, string.Empty);
|
||||
|
||||
/// <summary>The one answer to anything that would change state in view-as mode (portal error text).</summary>
|
||||
public const string ReadOnlyMessage = "Read-only view: changes are disabled while you view this tenant.";
|
||||
|
||||
public bool IsValid => TenantId.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Null when no tenant header is sent (an ordinary request); <see cref="Invalid"/> when it is malformed.
|
||||
/// The role defaults to Admin.
|
||||
/// </summary>
|
||||
public static ViewAsTarget? FromHeaders(string? tenantHeader, string? roleHeader)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantHeader))
|
||||
return null;
|
||||
if (!Guid.TryParse(tenantHeader.Trim(), out var tenant) || tenant == Guid.Empty)
|
||||
return Invalid;
|
||||
|
||||
var role = NormalizeRole(roleHeader);
|
||||
return role == null ? Invalid : new ViewAsTarget(tenant.ToString("D"), role);
|
||||
}
|
||||
|
||||
private static string? NormalizeRole(string? roleHeader)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(roleHeader))
|
||||
return Constants.TenantRoles.Admin;
|
||||
|
||||
var value = roleHeader.Trim();
|
||||
foreach (var role in new[] { Constants.TenantRoles.Admin, Constants.TenantRoles.Operator, Constants.TenantRoles.Viewer })
|
||||
{
|
||||
if (string.Equals(value, role, StringComparison.OrdinalIgnoreCase))
|
||||
return role;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+38
-13
@@ -12,7 +12,8 @@ using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
|
||||
@@ -204,8 +205,10 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
{
|
||||
await WriteCacheEntryAsync(tenantId, r, displayName: null, fileName: null, notFound: true, budget).ConfigureAwait(false);
|
||||
}
|
||||
else if (displayName != null)
|
||||
else
|
||||
{
|
||||
// A 200 answers for good even without a name: cached like the full pull caches it, a nameless
|
||||
// script costs no Graph call on every view until the next full pull.
|
||||
result[r] = displayName;
|
||||
await WriteCacheEntryAsync(tenantId, r, displayName, fileName: null, notFound: false, budget).ConfigureAwait(false);
|
||||
}
|
||||
@@ -284,15 +287,9 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
throw new HttpRequestException($"Graph LIST {url} returned {(int)status}");
|
||||
}
|
||||
|
||||
var json = JObject.Parse(body);
|
||||
var values = json["value"] as JArray ?? new JArray();
|
||||
foreach (var item in values.OfType<JObject>())
|
||||
var (scripts, nextUrl) = ReadScriptPage(body, withFileName: kind == ScriptKind.Platform);
|
||||
foreach (var (id, displayName, fileName) in scripts)
|
||||
{
|
||||
var id = item["id"]?.ToString();
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
|
||||
var displayName = item["displayName"]?.ToString();
|
||||
var fileName = kind == ScriptKind.Platform ? item["fileName"]?.ToString() : null;
|
||||
entries.Add(new ScriptDisplayNameEntry
|
||||
{
|
||||
TenantId = tenantId,
|
||||
@@ -314,7 +311,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
}
|
||||
}
|
||||
|
||||
url = json["@odata.nextLink"]?.ToString();
|
||||
url = nextUrl;
|
||||
}
|
||||
|
||||
if (entries.Count > 0)
|
||||
@@ -352,10 +349,38 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
|
||||
throw new HttpRequestException($"Graph GET {url} returned {(int)status}");
|
||||
}
|
||||
|
||||
var json = JObject.Parse(body);
|
||||
return (json["displayName"]?.ToString(), false);
|
||||
return (ReadScriptDisplayName(body), false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one page of a script list: id, display name and (platform scripts only) file name of
|
||||
/// every object entry with an id, plus the next page's url. A body that is not a JSON object
|
||||
/// throws, which ends the full pull and falls back to per-ID lookups.
|
||||
/// </summary>
|
||||
internal static (List<ScriptListItem> Scripts, string? NextUrl) ReadScriptPage(string body, bool withFileName)
|
||||
{
|
||||
var json = ParseScriptObject(body);
|
||||
var scripts = new List<ScriptListItem>();
|
||||
foreach (var item in UpstreamJson.Items(json, "value").Where(i => i.ValueKind == JsonValueKind.Object))
|
||||
{
|
||||
var id = UpstreamJson.GetString(item, "id");
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
scripts.Add(new ScriptListItem(id!, UpstreamJson.GetString(item, "displayName"),
|
||||
withFileName ? UpstreamJson.GetString(item, "fileName") : null));
|
||||
}
|
||||
return (scripts, UpstreamJson.GetNextLink(json));
|
||||
}
|
||||
|
||||
/// <summary>Reads the display name of a single-script GET; a body that is not a JSON object throws.</summary>
|
||||
internal static string? ReadScriptDisplayName(string body) => UpstreamJson.GetString(ParseScriptObject(body), "displayName");
|
||||
|
||||
// An empty or null body must throw like any other unreadable one: the full pull then falls back to
|
||||
// per-ID lookups instead of recording the list as fetched.
|
||||
private static JsonElement ParseScriptObject(string body)
|
||||
=> UpstreamJson.ParseObjectOrNull(body) ?? throw new JsonException("The Graph script response is empty or null.");
|
||||
|
||||
internal readonly record struct ScriptListItem(string Id, string? DisplayName, string? FileName);
|
||||
|
||||
private async Task WriteCacheEntryAsync(
|
||||
string tenantId, ScriptRef r, string? displayName, string? fileName, bool notFound, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Core;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// What one Kusto query against a telemetry store returned: the tables (plus the store's partial-result
|
||||
/// note) on success, or the store's status code and body when it answered non-2xx.
|
||||
/// </summary>
|
||||
public sealed class LogQueryOutcome
|
||||
{
|
||||
public IReadOnlyList<KqlTable>? Tables { get; init; }
|
||||
public string? PartialReason { get; init; }
|
||||
public HttpStatusCode? UpstreamStatus { get; init; }
|
||||
public string? UpstreamBody { get; init; }
|
||||
public long ElapsedMs { get; init; }
|
||||
|
||||
public bool Succeeded => Tables != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one Kusto query against a store from <see cref="LogQuerySourceCatalog"/> under the Function App's
|
||||
/// managed identity and a wall-clock budget (<c>Prefer: wait</c> upstream, a cancellation token here, no
|
||||
/// static client timeout). Shared by the GA-only KQL proxy (<c>QueryBackendLogs</c>), which forwards an
|
||||
/// operator's query verbatim, and by the fixed aggregate queries of the MCP usage overview. Throws
|
||||
/// <see cref="OperationCanceledException"/> when the budget runs out and lets credential errors through;
|
||||
/// each caller decides how to answer.
|
||||
/// </summary>
|
||||
public class LogQueryExecutor
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly TokenCredential _credential;
|
||||
|
||||
public LogQueryExecutor(IHttpClientFactory httpClientFactory, TokenCredential credential)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credential = credential;
|
||||
}
|
||||
|
||||
public virtual async Task<LogQueryOutcome> ExecuteAsync(LogQuerySource source, string query, string timespan, int budgetSeconds)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSeconds));
|
||||
|
||||
var token = await _credential.GetTokenAsync(new TokenRequestContext(new[] { source.TokenScope }), cts.Token);
|
||||
|
||||
using var upstreamRequest = new HttpRequestMessage(HttpMethod.Post, source.QueryUri);
|
||||
upstreamRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
// Ask the store to keep working as long as we are willing to wait (default server wait is shorter).
|
||||
upstreamRequest.Headers.TryAddWithoutValidation("Prefer", $"wait={budgetSeconds}");
|
||||
upstreamRequest.Content = new StringContent(
|
||||
JsonSerializer.Serialize(new KustoQuery(query, timespan)), Encoding.UTF8, "application/json");
|
||||
|
||||
var client = _httpClientFactory.CreateClient(LogQuerySourceCatalog.HttpClientName);
|
||||
using var upstreamResponse = await client.SendAsync(upstreamRequest, cts.Token);
|
||||
var responseText = await upstreamResponse.Content.ReadAsStringAsync(cts.Token);
|
||||
stopwatch.Stop();
|
||||
|
||||
if (!upstreamResponse.IsSuccessStatusCode)
|
||||
{
|
||||
return new LogQueryOutcome
|
||||
{
|
||||
UpstreamStatus = upstreamResponse.StatusCode,
|
||||
UpstreamBody = responseText,
|
||||
ElapsedMs = stopwatch.ElapsedMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
var (tables, partialReason) = ParseKustoBody(responseText);
|
||||
return new LogQueryOutcome { Tables = tables, PartialReason = partialReason, ElapsedMs = stopwatch.ElapsedMilliseconds };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kusto REST success body → typed tables plus the store's partial-result note. A 200 can carry
|
||||
/// <c>"error": { "code": "PartialError", ... }</c> next to <c>tables</c> when the result was cut
|
||||
/// (size cap, shard timeout); that used to pass through unread. Cells are cloned out of the
|
||||
/// document so they outlive it.
|
||||
/// </summary>
|
||||
internal static (IReadOnlyList<KqlTable> Tables, string? PartialReason) ParseKustoBody(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var tables = new List<KqlTable>();
|
||||
|
||||
if (root.TryGetProperty("tables", out var tablesEl) && tablesEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var t in tablesEl.EnumerateArray())
|
||||
{
|
||||
var columns = new List<KqlColumn>();
|
||||
if (t.TryGetProperty("columns", out var colsEl) && colsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var c in colsEl.EnumerateArray())
|
||||
{
|
||||
columns.Add(new KqlColumn
|
||||
{
|
||||
Name = c.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty,
|
||||
Type = c.TryGetProperty("type", out var ty) ? ty.GetString() ?? string.Empty : string.Empty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var rows = new List<IReadOnlyList<JsonElement>>();
|
||||
if (t.TryGetProperty("rows", out var rowsEl) && rowsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var r in rowsEl.EnumerateArray())
|
||||
{
|
||||
var cells = new List<JsonElement>(columns.Count);
|
||||
if (r.ValueKind == JsonValueKind.Array)
|
||||
foreach (var cell in r.EnumerateArray())
|
||||
cells.Add(cell.Clone());
|
||||
rows.Add(cells);
|
||||
}
|
||||
}
|
||||
|
||||
tables.Add(new KqlTable
|
||||
{
|
||||
Name = t.TryGetProperty("name", out var name) ? name.GetString() ?? string.Empty : string.Empty,
|
||||
Columns = columns,
|
||||
Rows = rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
string? partialReason = null;
|
||||
if (root.TryGetProperty("error", out var errorEl) && errorEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var (message, code) = ExtractError(errorEl);
|
||||
partialReason = string.IsNullOrEmpty(code) ? message : $"{code}: {message}";
|
||||
}
|
||||
|
||||
return (tables, partialReason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message + code from a Kusto error body — <c>innererror</c> first (the specific reason), then the
|
||||
/// outer <c>error</c>. Tolerates non-JSON.
|
||||
/// </summary>
|
||||
internal static (string Message, string? Code) ExtractError(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("error", out var errorObj) && errorObj.ValueKind == JsonValueKind.Object)
|
||||
return ExtractError(errorObj);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// not JSON — keep the generic message
|
||||
}
|
||||
return ("Telemetry store query failed", null);
|
||||
}
|
||||
|
||||
private static (string Message, string? Code) ExtractError(JsonElement errorObj)
|
||||
{
|
||||
var message = "Telemetry store query failed";
|
||||
string? code = null;
|
||||
|
||||
if (errorObj.TryGetProperty("innererror", out var inner) && inner.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (inner.TryGetProperty("message", out var innerMsg) && innerMsg.ValueKind == JsonValueKind.String)
|
||||
message = innerMsg.GetString() ?? message;
|
||||
if (inner.TryGetProperty("code", out var innerCode) && innerCode.ValueKind == JsonValueKind.String)
|
||||
code = innerCode.GetString();
|
||||
}
|
||||
if (message == "Telemetry store query failed" && errorObj.TryGetProperty("message", out var msg) && msg.ValueKind == JsonValueKind.String)
|
||||
message = msg.GetString() ?? message;
|
||||
if (code == null && errorObj.TryGetProperty("code", out var outerCode) && outerCode.ValueKind == JsonValueKind.String)
|
||||
code = outerCode.GetString();
|
||||
|
||||
return (message, code);
|
||||
}
|
||||
|
||||
/// <summary>Kusto REST request body — both App Insights and Log Analytics accept exactly this.</summary>
|
||||
private sealed record KustoQuery(
|
||||
[property: JsonPropertyName("query")] string Query,
|
||||
[property: JsonPropertyName("timespan")] string Timespan);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The quota wire nodes the MCP usage views share (the tenant's reporting page, the GA overview), built from the
|
||||
/// quota counters and resolved plan limits — one place, so both views show the same windows the same way.
|
||||
/// </summary>
|
||||
internal static class McpQuotaNodes
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure: the tenant's organization windows from its counter rows — today's and this month's total over every
|
||||
/// account charged to the tenant, against the tenant plan's limits (0 = unlimited).
|
||||
/// </summary>
|
||||
internal static McpOrganizationQuotaNode BuildOrganizationQuota(
|
||||
IEnumerable<TenantUsageRecord> records, McpQuotaService.TenantPlanLimits limits, string today, string monthStart)
|
||||
{
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
foreach (var record in records)
|
||||
{
|
||||
if (record.Date == today) dailyUsed += record.RequestCount;
|
||||
if (string.CompareOrdinal(record.Date, monthStart) >= 0 && string.CompareOrdinal(record.Date, today) <= 0)
|
||||
monthlyUsed += record.RequestCount;
|
||||
}
|
||||
|
||||
return new McpOrganizationQuotaNode
|
||||
{
|
||||
TenantPlan = limits.TenantPlan,
|
||||
DailyLimit = limits.TenantDailyLimit,
|
||||
MonthlyLimit = limits.TenantMonthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
PurchasedDelegatedSlots = WithSlots(limits.PurchasedDelegatedSlots, limits.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantDailyLimit),
|
||||
SlotMonthlyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantMonthlyLimit),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The slot breakdown is only worth sending when a slot was actually purchased.</summary>
|
||||
internal static int? WithSlots(int purchasedSlots, int value) => purchasedSlots > 0 ? value : null;
|
||||
}
|
||||
@@ -3,9 +3,9 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Caching;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Config;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
@@ -38,23 +38,28 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Counters: user counters ride on the UserUsageLog table (PK = oid), tenant counters on the
|
||||
/// McpTenantUsage table (PK = home tenantId, RK = {yyyyMMdd}_{oid} — one partition read per check, no
|
||||
/// hot row); both are written fire-and-forget by McpQuotaEnforcementMiddleware for X-Client-Source: mcp
|
||||
/// requests. Daily = today's rows, monthly = the sum over the month. The usage SNAPSHOTS are cached for
|
||||
/// 60 seconds — per user (oid) and per tenant — so the worst-case overshoot is bounded
|
||||
/// (limit + 60s × request-rate) — the same posture as the sliding-window rate limiter. Limits are
|
||||
/// re-resolved per check from services that carry their own caches, so a plan change applies at once.
|
||||
/// requests. Daily = today's rows, monthly = the sum over the month. Each instance reads an account's and an
|
||||
/// organization's counters from storage at most every 60 seconds and counts every request it admits on
|
||||
/// them at once (<see cref="AdmitAsync"/>), so one instance blocks exactly at the limit. Across scaled-out
|
||||
/// instances the boundary stays soft by what the OTHER instances admitted inside those 60 seconds, plus
|
||||
/// increments still on their way to storage. Limits are re-resolved per check from services that carry their
|
||||
/// own caches, so a plan change applies at once.
|
||||
/// </summary>
|
||||
public class McpQuotaService
|
||||
{
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
|
||||
|
||||
internal static string UserCacheKey(string oid) => $"mcp-quota:user:{oid}";
|
||||
internal static string TenantCacheKey(string tenantId) => $"mcp-quota:tenant:{tenantId.ToLowerInvariant()}";
|
||||
// The UTC day is part of the key: a counter read before midnight never answers a check after it.
|
||||
private static string UserCacheKey(string oid, string day) => $"mcp-quota:user:{oid}:{day}";
|
||||
private static string TenantCacheKey(string tenantId, string day) => $"mcp-quota:tenant:{tenantId.ToLowerInvariant()}:{day}";
|
||||
|
||||
private readonly IUserUsageRepository _usageRepo;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantEntitlementService _entitlementService;
|
||||
private readonly IMemoryCache _cache;
|
||||
// Single-flight: concurrent first checks of one account share ONE counter instead of each counting on a
|
||||
// private copy that the last cache write would discard.
|
||||
private readonly SingleFlightCache<UsageCounter> _counters;
|
||||
private readonly ILogger<McpQuotaService> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
@@ -63,19 +68,17 @@ namespace AutopilotMonitor.Functions.Services
|
||||
McpUserService mcpUserService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantEntitlementService entitlementService,
|
||||
IMemoryCache cache,
|
||||
ILogger<McpQuotaService> logger)
|
||||
: this(usageRepo, mcpUserService, adminConfigService, entitlementService, cache, logger, TimeProvider.System)
|
||||
: this(usageRepo, mcpUserService, adminConfigService, entitlementService, logger, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic window math.</summary>
|
||||
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic window math and counter expiry.</summary>
|
||||
public McpQuotaService(
|
||||
IUserUsageRepository usageRepo,
|
||||
McpUserService mcpUserService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantEntitlementService entitlementService,
|
||||
IMemoryCache cache,
|
||||
ILogger<McpQuotaService> logger,
|
||||
TimeProvider time)
|
||||
{
|
||||
@@ -83,19 +86,32 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_mcpUserService = mcpUserService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_entitlementService = entitlementService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
_counters = new SingleFlightCache<UsageCounter>(() => time.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the caller's effective plans + limits and checks the current user AND tenant usage against
|
||||
/// them. <paramref name="tenantId"/> is the token's tid — the identity the per-user override is bound to
|
||||
/// and the tenant whose organization budget every request of this caller draws on, a delegated read
|
||||
/// included. Fail-open on counter/storage errors (a broken quota check must not take down MCP);
|
||||
/// fail-closed on plan resolution (unknown plan → Community).
|
||||
/// The enforcement step for ONE MCP request the caller is about to serve: resolves the caller's effective
|
||||
/// plans + limits, checks the current user AND tenant usage against them and, when allowed, counts the
|
||||
/// request on this instance's counters at once (the middleware also writes it to storage). Only
|
||||
/// McpQuotaEnforcementMiddleware calls this; a view of the quota uses <see cref="ReadStateAsync"/>.
|
||||
/// <paramref name="tenantId"/> is the token's tid — the identity the per-user override is bound to and the
|
||||
/// tenant whose organization budget every request of this caller draws on, a delegated read included. The
|
||||
/// decision's used values are the ones BEFORE this request. Fail-open on counter/storage errors (a broken
|
||||
/// quota check must not take down MCP); fail-closed on plan resolution (unknown plan → Community).
|
||||
/// </summary>
|
||||
public virtual async Task<McpQuotaDecision> CheckAsync(string oid, string? upn, string? tenantId)
|
||||
public virtual Task<McpQuotaDecision> AdmitAsync(string oid, string? upn, string? tenantId)
|
||||
=> EvaluateAsync(oid, upn, tenantId, count: true);
|
||||
|
||||
/// <summary>
|
||||
/// The same decision as <see cref="AdmitAsync"/> for a page that SHOWS the quota: what the caller's next
|
||||
/// request would meet, without counting anything.
|
||||
/// </summary>
|
||||
public virtual Task<McpQuotaDecision> ReadStateAsync(string oid, string? upn, string? tenantId)
|
||||
=> EvaluateAsync(oid, upn, tenantId, count: false);
|
||||
|
||||
private async Task<McpQuotaDecision> EvaluateAsync(string oid, string? upn, string? tenantId, bool count)
|
||||
{
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
var limits = await ResolvePlanAsync(AdminIdentity.Create(upn, tenantId, oid), tenantId);
|
||||
@@ -108,7 +124,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (tenant == null)
|
||||
return McpQuotaDecision.FailOpen(limits);
|
||||
|
||||
return BuildDecision(limits, user.DailyUsed, user.MonthlyUsed, tenant.DailyUsed, tenant.MonthlyUsed, nowUtc);
|
||||
return Decide(limits, user, tenant, nowUtc, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision and count in one step, so parallel requests of one caller (a tool call fans out several) can
|
||||
/// never all pass on the same reading. Every check locks the account's counter before the organization's,
|
||||
/// so two members of one tenant never wait on each other in a cycle.
|
||||
/// </summary>
|
||||
private static McpQuotaDecision Decide(McpPlanLimits limits, UsageCounter user, UsageCounter tenant, DateTime nowUtc, bool count)
|
||||
{
|
||||
lock (user)
|
||||
lock (tenant)
|
||||
{
|
||||
var decision = BuildDecision(limits, user.DailyUsed, user.MonthlyUsed, tenant.DailyUsed, tenant.MonthlyUsed, nowUtc);
|
||||
if (count && decision.Allowed)
|
||||
{
|
||||
user.Count();
|
||||
tenant.Count();
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -222,54 +258,49 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The caller's own counters (cached snapshot). Null = read failed (fail-open, nothing cached).</summary>
|
||||
private async Task<UsageSnapshot?> ReadUserUsageAsync(string oid, DateTime nowUtc)
|
||||
/// <summary>The caller's own counter (cached, single-flight). Null = read failed (fail-open, nothing cached).</summary>
|
||||
private async Task<UsageCounter?> ReadUserUsageAsync(string oid, DateTime nowUtc)
|
||||
{
|
||||
var cacheKey = UserCacheKey(oid);
|
||||
if (_cache.TryGetValue<UsageSnapshot>(cacheKey, out var cached) && cached != null)
|
||||
return cached;
|
||||
|
||||
var (monthStart, today) = Window(nowUtc);
|
||||
try
|
||||
{
|
||||
var (monthStart, today) = Window(nowUtc);
|
||||
var records = await _usageRepo.GetUsageByUserAsync(oid, monthStart, today);
|
||||
var snapshot = new UsageSnapshot(
|
||||
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
|
||||
records.Sum(r => r.RequestCount));
|
||||
_cache.Set(cacheKey, snapshot, CacheDuration);
|
||||
return snapshot;
|
||||
return await _counters.GetOrAddAsync(UserCacheKey(oid, today), CacheDuration, async () =>
|
||||
{
|
||||
var records = await _usageRepo.GetUsageByUserAsync(oid, monthStart, today);
|
||||
return new UsageCounter(
|
||||
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
|
||||
records.Sum(r => r.RequestCount));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail-open: usage counters unavailable → allow. Do NOT cache — the next request retries the read.
|
||||
// Fail-open: usage counters unavailable → allow. The failed read is not cached — the next request retries it.
|
||||
_logger.LogWarning(ex, "[McpQuota] Usage lookup failed for oid={Oid} — allowing (fail-open)", oid);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tenant's organization counters (cached snapshot, shared by every member of that tenant).
|
||||
/// Skipped — zero counters, no read, nothing cached — when nothing could ever block on it (no tenant,
|
||||
/// or both windows lifted). Null = read failed (fail-open, nothing cached).
|
||||
/// The tenant's organization counter (cached, single-flight, shared by every member of that tenant on this
|
||||
/// instance). Skipped — no read, nothing cached — when nothing could ever block on it (no tenant, or both
|
||||
/// windows lifted): the check then gets a zero counter of its own, never a shared one, because
|
||||
/// <see cref="Decide"/> raises whatever it is handed. Null = read failed (fail-open, nothing cached).
|
||||
/// </summary>
|
||||
private async Task<UsageSnapshot?> ReadTenantUsageAsync(string? tenantId, McpPlanLimits limits, DateTime nowUtc)
|
||||
private async Task<UsageCounter?> ReadTenantUsageAsync(string? tenantId, McpPlanLimits limits, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || (limits.TenantDailyLimit <= 0 && limits.TenantMonthlyLimit <= 0))
|
||||
return UsageSnapshot.Zero;
|
||||
|
||||
var cacheKey = TenantCacheKey(tenantId);
|
||||
if (_cache.TryGetValue<UsageSnapshot>(cacheKey, out var cached) && cached != null)
|
||||
return cached;
|
||||
return new UsageCounter(0, 0);
|
||||
|
||||
var (monthStart, today) = Window(nowUtc);
|
||||
try
|
||||
{
|
||||
var (monthStart, today) = Window(nowUtc);
|
||||
var records = await _usageRepo.GetTenantUsageAsync(tenantId, monthStart, today);
|
||||
var snapshot = new UsageSnapshot(
|
||||
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
|
||||
records.Sum(r => r.RequestCount));
|
||||
_cache.Set(cacheKey, snapshot, CacheDuration);
|
||||
return snapshot;
|
||||
return await _counters.GetOrAddAsync(TenantCacheKey(tenantId, today), CacheDuration, async () =>
|
||||
{
|
||||
var records = await _usageRepo.GetTenantUsageAsync(tenantId, monthStart, today);
|
||||
return new UsageCounter(
|
||||
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
|
||||
records.Sum(r => r.RequestCount));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -334,10 +365,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
private static bool Exceeded(int limit, long used) => limit > 0 && used >= limit;
|
||||
|
||||
/// <summary>Cached counter pair (today / this month) for one user or one tenant.</summary>
|
||||
private sealed record UsageSnapshot(long DailyUsed, long MonthlyUsed)
|
||||
/// <summary>
|
||||
/// Today / this month for one account or one organization as this instance knows it: read from storage at
|
||||
/// most every <see cref="CacheDuration"/>, raised by every request this instance admits in between. Only
|
||||
/// <see cref="Decide"/> touches it, under its monitor.
|
||||
/// </summary>
|
||||
private sealed class UsageCounter
|
||||
{
|
||||
public static readonly UsageSnapshot Zero = new(0, 0);
|
||||
public UsageCounter(long dailyUsed, long monthlyUsed)
|
||||
{
|
||||
DailyUsed = dailyUsed;
|
||||
MonthlyUsed = monthlyUsed;
|
||||
}
|
||||
|
||||
public long DailyUsed { get; private set; }
|
||||
public long MonthlyUsed { get; private set; }
|
||||
|
||||
public void Count()
|
||||
{
|
||||
DailyUsed++;
|
||||
MonthlyUsed++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The home tenant's plan facts a resolution needs, read once per check.</summary>
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the all-tenant MCP usage overview of the Global Admin metrics page (D-416). Two kinds of source, never
|
||||
/// mixed inside one number: the quota counters (<c>McpTenantUsage</c> for tenants, accounts and days; each
|
||||
/// account's quota state through <see cref="McpQuotaService.ReadStateAsync"/>, exactly what its next request
|
||||
/// meets) for every "request" and every window, and the telemetry stores (<see cref="McpUsageTelemetryQueries"/>)
|
||||
/// for calls, tools, refusals and per-minute peaks. A store that does not answer empties only its own parts.
|
||||
/// </summary>
|
||||
public class McpUsageOverviewService
|
||||
{
|
||||
internal const int MaxDays = 90;
|
||||
private const int TelemetryBudgetSeconds = 60;
|
||||
private const int AccountReadParallelism = 8;
|
||||
|
||||
private readonly IUserUsageRepository _usageRepo;
|
||||
private readonly McpQuotaService _quota;
|
||||
private readonly GlobalAdminService _globalAdmins;
|
||||
private readonly AdminConfigurationService _adminConfig;
|
||||
private readonly TenantConfigurationService _tenantConfig;
|
||||
private readonly TenantEntitlementService _entitlements;
|
||||
private readonly LogQuerySourceCatalog _sources;
|
||||
private readonly LogQueryExecutor _executor;
|
||||
private readonly ILogger<McpUsageOverviewService> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public McpUsageOverviewService(
|
||||
IUserUsageRepository usageRepo, McpQuotaService quota, GlobalAdminService globalAdmins,
|
||||
AdminConfigurationService adminConfig, TenantConfigurationService tenantConfig, TenantEntitlementService entitlements,
|
||||
LogQuerySourceCatalog sources, LogQueryExecutor executor, ILogger<McpUsageOverviewService> logger)
|
||||
: this(usageRepo, quota, globalAdmins, adminConfig, tenantConfig, entitlements, sources, executor, logger, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
internal McpUsageOverviewService(
|
||||
IUserUsageRepository usageRepo, McpQuotaService quota, GlobalAdminService globalAdmins,
|
||||
AdminConfigurationService adminConfig, TenantConfigurationService tenantConfig, TenantEntitlementService entitlements,
|
||||
LogQuerySourceCatalog sources, LogQueryExecutor executor, ILogger<McpUsageOverviewService> logger, TimeProvider time)
|
||||
{
|
||||
_usageRepo = usageRepo;
|
||||
_quota = quota;
|
||||
_globalAdmins = globalAdmins;
|
||||
_adminConfig = adminConfig;
|
||||
_tenantConfig = tenantConfig;
|
||||
_entitlements = entitlements;
|
||||
_sources = sources;
|
||||
_executor = executor;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async Task<GetGlobalMcpUsageOverviewResponse> BuildAsync(int days, bool includePlatformAdmins)
|
||||
{
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
var windowDays = Math.Clamp(days, 1, MaxDays);
|
||||
var window = new Window(nowUtc.Date.AddDays(1 - windowDays), nowUtc.Date.AddDays(1), includePlatformAdmins);
|
||||
var dates = Dates.For(window, nowUtc);
|
||||
|
||||
var countersTask = _usageRepo.GetAllTenantUsageAsync(dates.ReadFrom, dates.Today);
|
||||
var telemetryTask = ReadTelemetryAsync(window);
|
||||
await Task.WhenAll(countersTask, telemetryTask);
|
||||
var counters = countersTask.Result;
|
||||
var telemetry = telemetryTask.Result;
|
||||
|
||||
// Every counter account with use in the window; its quota state and platform role come from the services.
|
||||
var principals = PrincipalsByAccount(counters);
|
||||
var counterAccounts = counters
|
||||
.Where(r => !string.IsNullOrEmpty(r.UserId) && dates.InWindow(r.Date))
|
||||
.Select(r => (Tenant: r.TenantId.ToLowerInvariant(), r.UserId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var telemetryRoles = LatestRoles(telemetry.Calls);
|
||||
|
||||
var facts = new System.Collections.Concurrent.ConcurrentDictionary<(string Tenant, string Principal), AccountFacts>();
|
||||
await Parallel.ForEachAsync(counterAccounts, new ParallelOptions { MaxDegreeOfParallelism = AccountReadParallelism }, async (account, _) =>
|
||||
{
|
||||
var principal = principals[account];
|
||||
var platformRole = await _globalAdmins.GetGlobalRoleAsync(AdminIdentity.Create(principal, account.Tenant, account.UserId));
|
||||
var state = await _quota.ReadStateAsync(account.UserId, principal, account.Tenant);
|
||||
facts[(account.Tenant, principal)] = new AccountFacts(account.UserId, platformRole, state);
|
||||
});
|
||||
|
||||
// Every tenant the overview names — the per-minute rows included — with its plan and its configuration, read once.
|
||||
var tenants = counters.Select(r => r.TenantId.ToLowerInvariant())
|
||||
.Concat(telemetry.Calls.Select(r => r.Tenant))
|
||||
.Concat(telemetry.Peaks.Select(p => p.Tenant))
|
||||
.Concat(telemetry.Refusals.Select(r => r.Tenant))
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var tenantLimits = new System.Collections.Concurrent.ConcurrentDictionary<string, McpQuotaService.TenantPlanLimits>(StringComparer.Ordinal);
|
||||
var tenantConfigs = new System.Collections.Concurrent.ConcurrentDictionary<string, TenantConfiguration?>(StringComparer.Ordinal);
|
||||
await Parallel.ForEachAsync(tenants, new ParallelOptions { MaxDegreeOfParallelism = AccountReadParallelism }, async (tenant, _) =>
|
||||
{
|
||||
tenantLimits[tenant] = await _quota.ResolveTenantPlanAsync(tenant);
|
||||
tenantConfigs[tenant] = await ReadTenantConfigurationAsync(tenant);
|
||||
});
|
||||
|
||||
var minuteLimits = await ResolveMinuteLimitsAsync(telemetry, facts, telemetryRoles, tenantConfigs);
|
||||
var tenantNames = tenantConfigs
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Value?.DomainName))
|
||||
.ToDictionary(c => c.Key, c => c.Value!.DomainName, StringComparer.Ordinal);
|
||||
|
||||
return Assemble(new OverviewInputs(
|
||||
dates, includePlatformAdmins, counters, facts, tenantLimits, tenantNames, telemetryRoles, minuteLimits, telemetry));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tenant's configuration (cached) for its name and its per-minute override. A storage failure leaves both
|
||||
/// out and is logged; it never fails the overview, which is about usage, not configuration.
|
||||
/// </summary>
|
||||
internal async Task<TenantConfiguration?> ReadTenantConfigurationAsync(string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _tenantConfig.GetConfigurationIfExistsAsync(tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpUsageOverview] Configuration of tenant {TenantId} unreadable: name and per-minute override left out",
|
||||
LogSanitizer.Clean(tenantId));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Telemetry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<TelemetryResult> ReadTelemetryAsync(Window window)
|
||||
{
|
||||
var calls = RunAsync(LogQuerySources.Backend, Calls(window), window, nameof(Calls));
|
||||
var routes = RunAsync(LogQuerySources.Backend, Routes(window), window, nameof(Routes));
|
||||
var refusals = RunAsync(LogQuerySources.Backend, Refusals(window), window, nameof(Refusals));
|
||||
var peaks = RunAsync(LogQuerySources.Backend, Peaks(window), window, nameof(Peaks));
|
||||
var quality = RunAsync(LogQuerySources.Mcp, ToolQuality(window), window, nameof(ToolQuality));
|
||||
var mcpRefusals = RunAsync(LogQuerySources.Mcp, McpRefusals(window), window, nameof(McpRefusals));
|
||||
var serverFacts = RunAsync(LogQuerySources.Mcp, McpServerFacts(window), window, nameof(McpServerFacts));
|
||||
await Task.WhenAll(calls, routes, refusals, peaks, quality, mcpRefusals, serverFacts);
|
||||
|
||||
var all = new[] { calls.Result, routes.Result, refusals.Result, peaks.Result, quality.Result, mcpRefusals.Result, serverFacts.Result };
|
||||
return new TelemetryResult(
|
||||
RequestsAvailable: calls.Result.Table != null,
|
||||
McpLogAvailable: quality.Result.Table != null,
|
||||
Partial: all.Any(r => r.Partial),
|
||||
Calls: calls.Result.Table is { } c ? ParseCalls(c) : new(),
|
||||
Routes: routes.Result.Table is { } r ? ParseRoutes(r) : new(),
|
||||
Refusals: refusals.Result.Table is { } f ? ParseRefusals(f) : new(),
|
||||
Peaks: peaks.Result.Table is { } p ? ParsePeaks(p) : new(),
|
||||
ToolQuality: quality.Result.Table is { } q ? ParseToolQuality(q) : new(),
|
||||
McpRefusals: mcpRefusals.Result.Table is { } m ? ParseMcpRefusals(m) : new(),
|
||||
ServerFacts: serverFacts.Result.Table is { } s ? ParseServerFacts(s) : new ServerFacts(null, null));
|
||||
}
|
||||
|
||||
/// <summary>One fixed query; any failure is logged with its cause and leaves the part empty, never the page.</summary>
|
||||
private async Task<(KqlTable? Table, bool Partial)> RunAsync(string sourceName, string query, Window window, string label)
|
||||
{
|
||||
if (!_sources.TryResolve(sourceName, out var source, out var unavailable))
|
||||
{
|
||||
_logger.LogWarning("[McpUsageOverview] {Query} skipped: {Reason}", label, unavailable);
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var outcome = await _executor.ExecuteAsync(source!, query, window.Timespan, TelemetryBudgetSeconds);
|
||||
if (!outcome.Succeeded || outcome.Tables!.Count == 0)
|
||||
{
|
||||
var (message, code) = LogQueryExecutor.ExtractError(outcome.UpstreamBody ?? string.Empty);
|
||||
_logger.LogWarning("[McpUsageOverview] {Query} failed on {Source}: {Status} {Code} {Message}",
|
||||
label, sourceName, outcome.UpstreamStatus, code, message);
|
||||
return (null, false);
|
||||
}
|
||||
if (outcome.PartialReason != null)
|
||||
_logger.LogWarning("[McpUsageOverview] {Query} on {Source} was cut: {Reason}", label, sourceName, outcome.PartialReason);
|
||||
return (outcome.Tables[0], outcome.PartialReason != null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpUsageOverview] {Query} failed on {Source}", label, sourceName);
|
||||
return (null, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-minute limits ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The backend per-minute limit each account with a peak or a refusal meets in its HOME tenant — the same
|
||||
/// precedence as UserRateLimitMiddleware on the integration surface: the Global Admin budget, else the
|
||||
/// tenant override, else the default raised to the edition floor (a Global Reader gets neither override nor
|
||||
/// floor). A delegated reader can meet another tenant's override when it reads there; the page shows home.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<(string Tenant, string Principal), int>> ResolveMinuteLimitsAsync(
|
||||
TelemetryResult telemetry, IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> facts,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), string> telemetryRoles,
|
||||
IReadOnlyDictionary<string, TenantConfiguration?> tenantConfigs)
|
||||
{
|
||||
var config = await _adminConfig.GetConfigurationAsync();
|
||||
var keys = telemetry.Peaks.Select(p => (p.Tenant, p.Principal))
|
||||
.Concat(telemetry.Refusals.Where(r => r.Gate == RefusalMinute).Select(r => (r.Tenant, r.Principal)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var limits = new Dictionary<(string Tenant, string Principal), int>();
|
||||
var perTenant = new Dictionary<string, (int? Override, int? Floor)>(StringComparer.Ordinal);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var role = facts.TryGetValue(key, out var f) && f.PlatformRole != null
|
||||
? f.PlatformRole
|
||||
: telemetryRoles.GetValueOrDefault(key);
|
||||
var isGlobalAdmin = role == Constants.GlobalRoles.GlobalAdmin;
|
||||
var hasGlobalScope = isGlobalAdmin || role == Constants.GlobalRoles.GlobalReader;
|
||||
|
||||
(int? Override, int? Floor) tenantSide = (null, null);
|
||||
if (!hasGlobalScope && !string.IsNullOrEmpty(key.Tenant))
|
||||
{
|
||||
if (!perTenant.TryGetValue(key.Tenant, out tenantSide))
|
||||
{
|
||||
var entitlements = await _entitlements.GetEntitlementsAsync(key.Tenant);
|
||||
tenantSide = (tenantConfigs.GetValueOrDefault(key.Tenant)?.CustomUserRateLimitRequestsPerMinute, entitlements.UserRateLimitPerMinute);
|
||||
perTenant[key.Tenant] = tenantSide;
|
||||
}
|
||||
}
|
||||
|
||||
limits[key] = RateLimitResolver.ResolveUserLimit(
|
||||
ThrottleSurface.Integration, isGlobalAdmin, tenantSide.Override,
|
||||
config.UserRateLimitRequestsPerMinute, config.PortalUserRateLimitRequestsPerMinute,
|
||||
config.GlobalAdminRateLimitRequestsPerMinute, tenantSide.Floor);
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
// ── Assembly (pure) ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The principal each counter account (lower-cased tenant, object id) stands for: the UPN of its newest row
|
||||
/// that carries one, lower-cased like the request telemetry; the object id for an account whose rows all
|
||||
/// predate the UPN column.
|
||||
/// </summary>
|
||||
internal static Dictionary<(string Tenant, string UserId), string> PrincipalsByAccount(IEnumerable<TenantUsageRecord> counters)
|
||||
=> counters
|
||||
.Where(r => !string.IsNullOrEmpty(r.UserId))
|
||||
.GroupBy(r => (Tenant: r.TenantId.ToLowerInvariant(), r.UserId))
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (g.OrderByDescending(r => r.Date, StringComparer.Ordinal)
|
||||
.Select(r => r.UserPrincipalName)
|
||||
.FirstOrDefault(u => !string.IsNullOrEmpty(u)) ?? g.Key.UserId).ToLowerInvariant());
|
||||
|
||||
/// <summary>What the I/O part learned about one counter account: its object id, platform role and quota state.</summary>
|
||||
internal sealed record AccountFacts(string UserId, string? PlatformRole, McpQuotaDecision Quota);
|
||||
|
||||
internal sealed record TelemetryResult(
|
||||
bool RequestsAvailable, bool McpLogAvailable, bool Partial,
|
||||
List<CallRow> Calls, List<RouteRow> Routes, List<RefusalRow> Refusals, List<PeakRow> Peaks,
|
||||
List<ToolQualityRow> ToolQuality, List<McpRefusalRow> McpRefusals, ServerFacts ServerFacts);
|
||||
|
||||
/// <summary>The window as yyyyMMdd strings (they sort as text).</summary>
|
||||
internal sealed record Dates(string From, string Today, string MonthStart)
|
||||
{
|
||||
/// <summary>One read covers the window and the current month (the quota windows).</summary>
|
||||
public string ReadFrom => string.CompareOrdinal(From, MonthStart) < 0 ? From : MonthStart;
|
||||
|
||||
public bool InWindow(string day) => string.CompareOrdinal(day, From) >= 0 && string.CompareOrdinal(day, Today) <= 0;
|
||||
|
||||
public IEnumerable<string> Days()
|
||||
{
|
||||
var day = DateTime.ParseExact(From, "yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
var last = DateTime.ParseExact(Today, "yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
for (; day <= last; day = day.AddDays(1))
|
||||
yield return day.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static Dates For(Window window, DateTime nowUtc)
|
||||
=> new(
|
||||
window.FromUtc.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
|
||||
nowUtc.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
|
||||
new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("yyyyMMdd", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
internal sealed record OverviewInputs(
|
||||
Dates Dates,
|
||||
bool IncludePlatformAdmins,
|
||||
IReadOnlyList<TenantUsageRecord> Counters,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> Accounts,
|
||||
IReadOnlyDictionary<string, McpQuotaService.TenantPlanLimits> TenantLimits,
|
||||
IReadOnlyDictionary<string, string> TenantNames,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), string> TelemetryRoles,
|
||||
IReadOnlyDictionary<(string Tenant, string Principal), int> MinuteLimits,
|
||||
TelemetryResult Telemetry);
|
||||
|
||||
/// <summary>The role of each account's latest call (lower-cased tenant and principal as key).</summary>
|
||||
internal static Dictionary<(string Tenant, string Principal), string> LatestRoles(IEnumerable<CallRow> calls)
|
||||
=> calls
|
||||
.Where(c => !string.IsNullOrEmpty(c.Role))
|
||||
.GroupBy(c => (c.Tenant, c.Principal))
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(c => c.LastAt ?? DateTime.MinValue).First().Role);
|
||||
|
||||
private static bool IsPlatformRole(string? role)
|
||||
=> role == Constants.GlobalRoles.GlobalAdmin || role == Constants.GlobalRoles.GlobalReader;
|
||||
|
||||
/// <summary>
|
||||
/// Pure: folds counters, account facts and telemetry into the response. Platform admins drop out of every
|
||||
/// number unless included — except a tenant's organization windows, which stay the real counters.
|
||||
/// </summary>
|
||||
internal static GetGlobalMcpUsageOverviewResponse Assemble(OverviewInputs input)
|
||||
{
|
||||
var dates = input.Dates;
|
||||
var telemetry = input.Telemetry;
|
||||
|
||||
string? RoleOf((string Tenant, string Principal) key)
|
||||
=> input.TelemetryRoles.TryGetValue(key, out var role) ? role
|
||||
: input.Accounts.TryGetValue(key, out var facts) ? facts.PlatformRole
|
||||
: null;
|
||||
|
||||
bool IsPlatform((string Tenant, string Principal) key)
|
||||
=> IsPlatformRole(RoleOf(key))
|
||||
|| (input.Accounts.TryGetValue(key, out var facts) && IsPlatformRole(facts.PlatformRole));
|
||||
|
||||
bool Counts((string Tenant, string Principal) key) => input.IncludePlatformAdmins || !IsPlatform(key);
|
||||
|
||||
// Counter rows per account key — the same principal per account as the I/O part used for its facts.
|
||||
var principals = PrincipalsByAccount(input.Counters);
|
||||
var counterRows = input.Counters
|
||||
.Where(r => !string.IsNullOrEmpty(r.UserId))
|
||||
.Select(r =>
|
||||
{
|
||||
var tenant = r.TenantId.ToLowerInvariant();
|
||||
return (Row: r, Key: (Tenant: tenant, Principal: principals[(tenant, r.UserId)]));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var windowRows = counterRows.Where(x => dates.InWindow(x.Row.Date) && Counts(x.Key)).ToList();
|
||||
var calls = telemetry.Calls.Where(c => Counts((c.Tenant, c.Principal))).ToList();
|
||||
var quotaHits = telemetry.Refusals
|
||||
.Where(r => r.Gate == RefusalQuota && Counts((r.Tenant, r.Principal)))
|
||||
.OrderByDescending(r => r.LastAt)
|
||||
.Select(r => new McpUsageQuotaHitItem
|
||||
{
|
||||
Date = r.Day,
|
||||
TenantId = r.Tenant,
|
||||
Principal = string.IsNullOrEmpty(r.Principal) ? null : r.Principal,
|
||||
Level = r.Level,
|
||||
Scope = r.Scope,
|
||||
Plan = r.Plan,
|
||||
Limit = r.Limit,
|
||||
Used = r.Used,
|
||||
Refused = r.Refused,
|
||||
FirstAt = r.FirstAt,
|
||||
LastAt = r.LastAt,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// ── Accounts ──
|
||||
var accountKeys = windowRows.Select(x => x.Key)
|
||||
.Concat(calls.Select(c => (c.Tenant, c.Principal)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var accounts = new Dictionary<(string Tenant, string Principal), McpUsageAccountItem>();
|
||||
foreach (var key in accountKeys)
|
||||
{
|
||||
var rows = windowRows.Where(x => x.Key == key).Select(x => x.Row).ToList();
|
||||
var accountCalls = calls.Where(c => (c.Tenant, c.Principal) == key).ToList();
|
||||
var byDay = rows.GroupBy(r => r.Date).Select(g => (Day: g.Key, Requests: g.Sum(r => r.RequestCount)))
|
||||
.OrderByDescending(d => d.Requests).ThenByDescending(d => d.Day, StringComparer.Ordinal).ToList();
|
||||
input.Accounts.TryGetValue(key, out var facts);
|
||||
var role = RoleOf(key);
|
||||
var lastCounter = rows.Select(r => r.LastRequestAt).Max();
|
||||
var lastCall = accountCalls.Select(c => c.LastAt).Max();
|
||||
|
||||
accounts[key] = new McpUsageAccountItem
|
||||
{
|
||||
UserId = facts?.UserId ?? rows.Select(r => r.UserId).FirstOrDefault(),
|
||||
Principal = key.Principal,
|
||||
Role = string.IsNullOrEmpty(role) ? null : role,
|
||||
IsPlatformAdmin = IsPlatform(key),
|
||||
Quota = facts == null ? null : new McpUsageAccountQuotaNode
|
||||
{
|
||||
Plan = facts.Quota.Plan,
|
||||
DailyLimit = facts.Quota.DailyLimit,
|
||||
MonthlyLimit = facts.Quota.MonthlyLimit,
|
||||
DailyUsed = facts.Quota.DailyUsed,
|
||||
MonthlyUsed = facts.Quota.MonthlyUsed,
|
||||
},
|
||||
Calls = accountCalls.Sum(c => c.Calls),
|
||||
Requests = rows.Sum(r => r.RequestCount),
|
||||
PeakDay = byDay.Count > 0 ? byDay[0].Day : null,
|
||||
PeakDayRequests = byDay.Count > 0 ? byDay[0].Requests : null,
|
||||
QuotaHits = quotaHits.Count(h => h.Level == McpQuotaLevel.User && h.TenantId == key.Tenant && h.Principal == key.Principal),
|
||||
TopTools = accountCalls.GroupBy(c => c.Tool)
|
||||
.Select(g => new McpUsageToolCallsItem { Tool = g.Key, Calls = g.Sum(c => c.Calls) })
|
||||
.OrderByDescending(t => t.Calls).ThenBy(t => t.Tool, StringComparer.Ordinal)
|
||||
.Take(3).ToList(),
|
||||
LastRequestAt = Later(lastCounter, lastCall),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tenants ──
|
||||
var tenants = accounts.Keys.Select(k => k.Tenant).Distinct(StringComparer.Ordinal)
|
||||
.Select(tenant =>
|
||||
{
|
||||
var members = accounts.Where(a => a.Key.Tenant == tenant).Select(a => a.Value)
|
||||
.OrderByDescending(a => a.Calls).ThenByDescending(a => a.Requests)
|
||||
.ThenBy(a => a.Principal, StringComparer.Ordinal).ToList();
|
||||
var limits = input.TenantLimits.TryGetValue(tenant, out var l) ? l : new McpQuotaService.TenantPlanLimits(string.Empty, 0, 0);
|
||||
// The organization windows are the tenant's real counters — every account, platform admins included.
|
||||
var tenantRows = counterRows.Where(x => x.Key.Tenant == tenant).Select(x => x.Row);
|
||||
return new McpUsageTenantItem
|
||||
{
|
||||
TenantId = tenant,
|
||||
DomainName = input.TenantNames.GetValueOrDefault(tenant),
|
||||
Quota = McpQuotaNodes.BuildOrganizationQuota(tenantRows, limits, dates.Today, dates.MonthStart),
|
||||
Calls = members.Sum(a => a.Calls),
|
||||
Requests = members.Sum(a => a.Requests),
|
||||
QuotaHits = quotaHits.Count(h => h.TenantId == tenant),
|
||||
LastRequestAt = members.Select(a => a.LastRequestAt).Max(),
|
||||
Accounts = members,
|
||||
};
|
||||
})
|
||||
.OrderByDescending(t => t.Calls).ThenByDescending(t => t.Requests).ThenBy(t => t.TenantId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// ── Tools ──
|
||||
var routesByTool = telemetry.Routes
|
||||
.GroupBy(r => r.Tool)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<McpUsageRouteItem>)g
|
||||
.OrderByDescending(r => r.Requests).ThenBy(r => r.Route, StringComparer.Ordinal)
|
||||
.Select(r => new McpUsageRouteItem { Route = r.Route, Requests = r.Requests }).ToList());
|
||||
var quality = telemetry.ToolQuality.ToDictionary(q => q.Tool, StringComparer.Ordinal);
|
||||
var backendTools = calls.GroupBy(c => c.Tool).ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
|
||||
|
||||
var tools = backendTools.Keys.Concat(quality.Keys).Distinct(StringComparer.Ordinal)
|
||||
.Where(tool => !string.IsNullOrEmpty(tool))
|
||||
.Select(tool =>
|
||||
{
|
||||
backendTools.TryGetValue(tool, out var rows);
|
||||
quality.TryGetValue(tool, out var q);
|
||||
var served = rows?.Sum(c => c.ServedCalls) ?? 0;
|
||||
return new McpUsageToolItem
|
||||
{
|
||||
Tool = tool,
|
||||
// Calls that reached the backend come from the request telemetry, like every account's calls;
|
||||
// a tool the backend never saw is counted from the MCP log.
|
||||
Calls = rows != null ? rows.Sum(c => c.Calls) : q!.Calls,
|
||||
Accounts = rows?.Select(c => (c.Tenant, c.Principal)).Distinct().Count(),
|
||||
Tenants = rows?.Select(c => c.Tenant).Distinct(StringComparer.Ordinal).Count(),
|
||||
RequestsPerCall = served > 0 ? Math.Round((double)rows!.Sum(c => c.Requests) / served, 2) : 0,
|
||||
ErrorRate = q != null && q.Calls > 0 ? Math.Round((double)q.Errors / q.Calls, 4) : null,
|
||||
P95DurationMs = q?.P95Ms,
|
||||
AvgResultChars = q?.AvgChars is double chars ? (long)Math.Round(chars) : null,
|
||||
OverCapCalls = q?.OverCap,
|
||||
Routes = routesByTool.TryGetValue(tool, out var routes) ? routes : Array.Empty<McpUsageRouteItem>(),
|
||||
};
|
||||
})
|
||||
.OrderByDescending(t => t.Calls).ThenBy(t => t.Tool, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// ── Days ──
|
||||
string PlanOf(string tenant) => input.TenantLimits.TryGetValue(tenant, out var l) && !string.IsNullOrEmpty(l.TenantPlan) ? l.TenantPlan : "unknown";
|
||||
var daily = dates.Days().Select(day =>
|
||||
{
|
||||
var dayCalls = calls.Where(c => c.Day == day).ToList();
|
||||
var dayRows = windowRows.Where(x => x.Row.Date == day).ToList();
|
||||
return new McpUsageDayItem
|
||||
{
|
||||
Date = day,
|
||||
Calls = dayCalls.Sum(c => c.Calls),
|
||||
Requests = dayRows.Sum(x => x.Row.RequestCount),
|
||||
Accounts = dayRows.Select(x => x.Key).Concat(dayCalls.Select(c => (c.Tenant, c.Principal))).Distinct().Count(),
|
||||
CallsByPlan = dayCalls.GroupBy(c => PlanOf(c.Tenant))
|
||||
.Select(g => new McpUsagePlanCallsItem { Plan = g.Key, Calls = g.Sum(c => c.Calls) })
|
||||
.OrderByDescending(p => p.Calls).ThenBy(p => p.Plan, StringComparer.Ordinal).ToList(),
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
// ── Per-minute limits ──
|
||||
var tenantOfPrincipal = accounts.Keys.GroupBy(k => k.Principal).ToDictionary(g => g.Key, g => g.First().Tenant, StringComparer.Ordinal);
|
||||
var mcpRefusals = telemetry.McpRefusals
|
||||
.Where(m => !string.IsNullOrEmpty(m.Principal))
|
||||
.Select(m => (Key: (Tenant: tenantOfPrincipal.GetValueOrDefault(m.Principal) ?? string.Empty, m.Principal), Row: m))
|
||||
.Where(m => Counts(m.Key))
|
||||
.ToDictionary(m => m.Key, m => m.Row);
|
||||
var backendRefusals = telemetry.Refusals
|
||||
.Where(r => r.Gate == RefusalMinute && Counts((r.Tenant, r.Principal)))
|
||||
.GroupBy(r => (r.Tenant, r.Principal))
|
||||
.ToDictionary(g => g.Key, g => (Refused: g.Sum(r => r.Refused), LastAt: g.Max(r => r.LastAt)));
|
||||
var peaks = telemetry.Peaks.Where(p => Counts((p.Tenant, p.Principal)))
|
||||
.GroupBy(p => (p.Tenant, p.Principal))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var mcpLimit = telemetry.ServerFacts.RateLimitPerMinute;
|
||||
// The higher of the two shares of a minute limit (0–1+); the list ranks by it and shows it in whole percent.
|
||||
double MinuteShare(long peakCalls, long peakRequests, int backendLimit) => Math.Max(
|
||||
mcpLimit is int limit && limit > 0 ? (double)peakCalls / limit : 0,
|
||||
backendLimit > 0 ? (double)peakRequests / backendLimit : 0);
|
||||
var minuteItems = peaks.Keys.Concat(mcpRefusals.Keys).Concat(backendRefusals.Keys).Distinct()
|
||||
.Select(key =>
|
||||
{
|
||||
peaks.TryGetValue(key, out var accountPeaks);
|
||||
var callPeak = accountPeaks?.FirstOrDefault(p => p.Metric == PeakCalls);
|
||||
var requestPeak = accountPeaks?.FirstOrDefault(p => p.Metric == PeakRequests);
|
||||
mcpRefusals.TryGetValue(key, out var mcpRefused);
|
||||
var hasBackendRefusal = backendRefusals.TryGetValue(key, out var backendRefused);
|
||||
var backendLimit = input.MinuteLimits.GetValueOrDefault(key);
|
||||
var share = MinuteShare(callPeak?.Peak ?? 0, requestPeak?.Peak ?? 0, backendLimit);
|
||||
return (Share: share, Item: new McpUsageMinuteItem
|
||||
{
|
||||
TenantId = key.Tenant,
|
||||
Principal = key.Principal,
|
||||
SharePercent = (int)Math.Round(share * 100, MidpointRounding.AwayFromZero),
|
||||
PeakCalls = callPeak?.Peak ?? 0,
|
||||
PeakCallsAt = callPeak?.PeakAt,
|
||||
McpRefused = mcpRefused?.Refused ?? 0,
|
||||
McpLastRefusedAt = mcpRefused?.LastAt,
|
||||
PeakRequests = requestPeak?.Peak ?? 0,
|
||||
PeakRequestsAt = requestPeak?.PeakAt,
|
||||
BackendLimit = backendLimit,
|
||||
BackendRefused = hasBackendRefusal ? backendRefused.Refused : 0,
|
||||
BackendLastRefusedAt = hasBackendRefusal ? backendRefused.LastAt : null,
|
||||
});
|
||||
})
|
||||
.OrderByDescending(m => m.Share)
|
||||
.ThenBy(m => m.Item.Principal, StringComparer.Ordinal)
|
||||
.Select(m => m.Item)
|
||||
.ToList();
|
||||
|
||||
var mcpLogFrom = telemetry.ServerFacts.Oldest is DateTime oldest
|
||||
? oldest.ToString("yyyyMMdd", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
return new GetGlobalMcpUsageOverviewResponse
|
||||
{
|
||||
DateFrom = dates.From,
|
||||
DateTo = dates.Today,
|
||||
IncludesPlatformAdmins = input.IncludePlatformAdmins,
|
||||
Telemetry = new McpUsageTelemetryStatus
|
||||
{
|
||||
RequestsAvailable = telemetry.RequestsAvailable,
|
||||
McpLogAvailable = telemetry.McpLogAvailable,
|
||||
McpLogFrom = mcpLogFrom != null && string.CompareOrdinal(mcpLogFrom, dates.From) > 0 ? mcpLogFrom : null,
|
||||
Partial = telemetry.Partial ? true : null,
|
||||
},
|
||||
Totals = BuildTotals(tenants, tools, quotaHits, minuteItems, telemetry),
|
||||
Daily = daily,
|
||||
Tenants = tenants,
|
||||
Tools = tools,
|
||||
QuotaHits = quotaHits,
|
||||
MinuteLimits = new McpUsageMinuteLimits { McpServerLimit = mcpLimit, Accounts = minuteItems },
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: the totals over the assembled lists. Calls need the request telemetry and MCP-only calls the MCP log —
|
||||
/// without the store the figure is absent, never a misleading partial sum. An account is a tenant and principal;
|
||||
/// an organization-level refusal counts for the account that met it.
|
||||
/// </summary>
|
||||
private static McpUsageTotalsNode BuildTotals(
|
||||
IReadOnlyList<McpUsageTenantItem> tenants, IReadOnlyList<McpUsageToolItem> tools,
|
||||
IReadOnlyList<McpUsageQuotaHitItem> quotaHits, IReadOnlyList<McpUsageMinuteItem> minuteItems, TelemetryResult telemetry)
|
||||
{
|
||||
var accounts = tenants.SelectMany(t => t.Accounts).ToList();
|
||||
var quotaHitAccounts = quotaHits.Select(h => (h.TenantId, Principal: h.Principal ?? string.Empty)).ToHashSet();
|
||||
var minuteRefusedAccounts = minuteItems
|
||||
.Where(m => m.McpRefused > 0 || m.BackendRefused > 0)
|
||||
.Select(m => (m.TenantId, m.Principal))
|
||||
.ToHashSet();
|
||||
|
||||
return new McpUsageTotalsNode
|
||||
{
|
||||
Calls = telemetry.RequestsAvailable ? tools.Sum(t => t.Calls) : null,
|
||||
McpOnlyCalls = telemetry.McpLogAvailable ? tools.Where(t => t.Accounts == null).Sum(t => t.Calls) : null,
|
||||
Tools = tools.Count,
|
||||
Requests = tenants.Sum(t => t.Requests),
|
||||
Tenants = tenants.Count,
|
||||
Accounts = accounts.Count,
|
||||
ServicePrincipals = accounts.Count(a => Constants.PrincipalKeys.IsApplication(a.Principal)),
|
||||
QuotaHitAccounts = quotaHitAccounts.Count,
|
||||
MinuteRefusedAccounts = minuteRefusedAccounts.Count,
|
||||
LimitAccounts = quotaHitAccounts.Union(minuteRefusedAccounts).Count(),
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime? Later(DateTime? a, DateTime? b) => a == null ? b : b == null ? a : (a > b ? a : b);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The fixed KQL behind the MCP usage overview and the parsers of its result tables. Two stores: the backend's
|
||||
/// App Insights (one request row per API call; the MCP stamps one correlation id per tool call on every request
|
||||
/// of that call) and the MCP Container App's Log Analytics workspace (one <c>tool_call</c> line per call, the
|
||||
/// <c>boot</c> line, the access guard's refusals). Every query covers whole UTC days [from, to) and leaves out
|
||||
/// platform admins (Global Admin, Global Reader) unless the window includes them.
|
||||
/// <para>
|
||||
/// Calls are counted exactly: grouped by correlation id first, then counted — never <c>dcount()</c>, which
|
||||
/// estimates. Log lines are matched by their START (<c>startswith</c>): a query that carries the text as an
|
||||
/// argument shows up inside a <c>tool_call</c> line and would match a <c>has</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class McpUsageTelemetryQueries
|
||||
{
|
||||
internal const string RefusalQuota = "quota";
|
||||
internal const string RefusalMinute = "minute";
|
||||
internal const string PeakRequests = "requests";
|
||||
internal const string PeakCalls = "calls";
|
||||
|
||||
/// <summary>Whole UTC days [FromUtc, ToUtc) plus whether platform admins count.</summary>
|
||||
internal sealed record Window(DateTime FromUtc, DateTime ToUtc, bool IncludePlatformAdmins)
|
||||
{
|
||||
/// <summary>Query-API timespan that covers the window (the where clauses bound it exactly).</summary>
|
||||
public string Timespan => $"P{Math.Max(1, (int)Math.Ceiling((ToUtc - FromUtc).TotalDays)) + 1}D";
|
||||
|
||||
public string From => Literal(FromUtc);
|
||||
public string To => Literal(ToUtc);
|
||||
|
||||
private static string Literal(DateTime utc) => $"datetime({utc.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)})";
|
||||
}
|
||||
|
||||
// ── App Insights (backend requests) ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The MCP request rows of the window with the columns every request query reads: tool, corr, tenant,
|
||||
/// principal, role. Leaves out the access probe (its rows name a tool that never ran) and, unless included,
|
||||
/// the platform admins.
|
||||
/// </summary>
|
||||
private static string McpRequests(Window w) => $$"""
|
||||
requests
|
||||
| where timestamp >= {{w.From}} and timestamp < {{w.To}}
|
||||
| where tostring(customDimensions.{{RequestRowDimensions.Source}}) == '{{RequestRowDimensions.WorkerMiddleware}}'
|
||||
| where tostring(customDimensions.{{RequestRowDimensions.ClientSource}}) =~ '{{ClientSourceHeader.Mcp}}'
|
||||
| where tostring(customDimensions.{{RequestRowDimensions.FunctionName}}) != '{{AccessProbeFunction.FunctionName}}'
|
||||
| extend tool = tostring(customDimensions.{{RequestRowDimensions.McpToolName}}), corr = tostring(customDimensions.{{RequestRowDimensions.CorrelationId}}), tenant = tolower(tostring(customDimensions.{{RequestRowDimensions.TenantId}})), principal = tolower(tostring(customDimensions.{{RequestRowDimensions.UserId}})), role = tostring(customDimensions.{{RequestRowDimensions.UserRole}})
|
||||
{{PlatformFilter(w, "role")}}
|
||||
""";
|
||||
|
||||
private static string PlatformFilter(Window w, string roleColumn) => w.IncludePlatformAdmins
|
||||
? string.Empty
|
||||
: $"| where {roleColumn} !in ('{Constants.GlobalRoles.GlobalAdmin}', '{Constants.GlobalRoles.GlobalReader}')";
|
||||
|
||||
/// <summary>A refused request (401/403/429) never reached the quota counters; everything else did.</summary>
|
||||
private const string Refused = "resultCode in ('401', '403', '429')";
|
||||
|
||||
/// <summary>
|
||||
/// Calls and charged requests per day (of the call's first request), tool, tenant and account, with the time
|
||||
/// and role of the row's latest call (one account can hold two roles: Viewer at home, DelegatedReader in a
|
||||
/// managed tenant). <c>servedCalls</c> = calls with at least one charged request — the denominator of requests per call.
|
||||
/// </summary>
|
||||
internal static string Calls(Window w) => $$"""
|
||||
{{McpRequests(w)}}
|
||||
| where isnotempty(tool) and isnotempty(corr)
|
||||
| summarize served = sum(iff({{Refused}}, 0, itemCount)), firstAt = min(timestamp), role = take_any(role) by corr, tool, tenant, principal
|
||||
| summarize calls = count(), servedCalls = countif(served > 0), charged = sum(served), arg_max(firstAt, role) by day = format_datetime(firstAt, 'yyyyMMdd'), tool, tenant, principal
|
||||
""";
|
||||
|
||||
/// <summary>Charged requests per tool and route (method plus path without /api/, ids as {id}).</summary>
|
||||
internal static string Routes(Window w) => $$"""
|
||||
{{McpRequests(w)}}
|
||||
| where isnotempty(tool) and not({{Refused}})
|
||||
| extend route = strcat(tostring(customDimensions.{{RequestRowDimensions.HttpMethod}}), ' ', tolower(replace_regex(replace_regex(tostring(customDimensions.{{RequestRowDimensions.HttpPath}}), @'^/api/', ''), @'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', '{id}')))
|
||||
| summarize charged = sum(itemCount) by tool, route
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Every 429 an MCP request met, classified by the Warning line the same invocation wrote: the daily/monthly
|
||||
/// quota (<see cref="McpQuotaEnforcementMiddleware.BlockedLogPrefix"/>) or the backend's per-minute limit
|
||||
/// (<see cref="UserRateLimitMiddleware.ThrottledLogPrefix"/>). One row per gate, day, account and window;
|
||||
/// <c>usedCount</c> is the window's count at the first refusal of the day.
|
||||
/// </summary>
|
||||
internal static string Refusals(Window w) => $$"""
|
||||
let rejected = {{McpRequests(w)}}
|
||||
| where resultCode == '429'
|
||||
| project timestamp, operation_Id, tenant, principal;
|
||||
let lines = traces
|
||||
| where timestamp >= {{w.From}} and timestamp < {{w.To}}
|
||||
| extend f = tostring(customDimensions.OriginalFormat)
|
||||
| where f startswith '{{McpQuotaEnforcementMiddleware.BlockedLogPrefix}}' or f startswith '{{UserRateLimitMiddleware.ThrottledLogPrefix}}'
|
||||
| project operation_Id, f, cd = customDimensions;
|
||||
rejected
|
||||
| join kind=inner lines on operation_Id
|
||||
| extend gate = iff(f startswith '{{McpQuotaEnforcementMiddleware.BlockedLogPrefix}}', '{{RefusalQuota}}', '{{RefusalMinute}}'), level = tostring(cd.Level), scope = tostring(cd.Scope), plan = tostring(cd.Plan)
|
||||
| extend usedCount = case(gate == '{{RefusalMinute}}', tolong(cd.Count), level == 'tenant' and scope == 'monthly', tolong(cd.TenantMonthlyUsed), level == 'tenant', tolong(cd.TenantDailyUsed), scope == 'monthly', tolong(cd.MonthlyUsed), tolong(cd.DailyUsed)),
|
||||
limitValue = case(gate == '{{RefusalMinute}}', tolong(cd.Max), level == 'tenant' and scope == 'monthly', tolong(cd.TenantMonthlyLimit), level == 'tenant', tolong(cd.TenantDailyLimit), scope == 'monthly', tolong(cd.MonthlyLimit), tolong(cd.DailyLimit))
|
||||
| summarize refused = count(), firstAt = min(timestamp), lastAt = max(timestamp), usedCount = min(usedCount), limitValue = max(limitValue) by gate, day = format_datetime(timestamp, 'yyyyMMdd'), tenant, principal, level, scope, plan
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Each account's busiest 60 seconds, measured like the sliding-window limiters: counts per 10-second bin,
|
||||
/// every bin added to the six windows it belongs to. <c>requests</c> counts every MCP request (refused ones
|
||||
/// too — they are demand); <c>calls</c> counts tool calls by their first request.
|
||||
/// </summary>
|
||||
internal static string Peaks(Window w) => $$"""
|
||||
let base = {{McpRequests(w)}}
|
||||
| project timestamp, corr, tenant, principal, itemCount;
|
||||
let requestPeaks = base
|
||||
| summarize n = sum(itemCount) by tenant, principal, b = bin(timestamp, 10s)
|
||||
| mv-expand k = range(0, 5, 1) to typeof(long)
|
||||
| summarize w = sum(n) by tenant, principal, e = b + k * 10s
|
||||
| summarize arg_max(w, e) by tenant, principal
|
||||
| extend metric = '{{PeakRequests}}';
|
||||
let callPeaks = base
|
||||
| where isnotempty(corr)
|
||||
| summarize at = min(timestamp) by corr, tenant, principal
|
||||
| summarize n = count() by tenant, principal, b = bin(at, 10s)
|
||||
| mv-expand k = range(0, 5, 1) to typeof(long)
|
||||
| summarize w = sum(n) by tenant, principal, e = b + k * 10s
|
||||
| summarize arg_max(w, e) by tenant, principal
|
||||
| extend metric = '{{PeakCalls}}';
|
||||
union requestPeaks, callPeaks
|
||||
| project metric, tenant, principal, peak = w, peakAt = e
|
||||
""";
|
||||
|
||||
// ── MCP Log Analytics (the MCP server's own lines) ─────────────────────────────
|
||||
|
||||
/// <summary>The <c>tool_call</c> line starts with this (its <c>type</c> key comes first).</summary>
|
||||
internal const string ToolCallLinePrefix = "{\"type\":\"tool_call\"";
|
||||
|
||||
/// <summary>The <c>boot</c> line starts with this.</summary>
|
||||
internal const string BootLinePrefix = "{\"type\":\"boot\"";
|
||||
|
||||
/// <summary>The access guard's per-account refusal line: <c>[mcp-auth] 429 kind=upn key=<principal> method=…</c>.</summary>
|
||||
internal const string McpRefusalLinePrefix = "[mcp-auth] 429 kind=upn ";
|
||||
|
||||
/// <summary>Per tool: calls, errors (soft errors included), cancellations, over-cap pages, p95 duration, mean answer size.</summary>
|
||||
internal static string ToolQuality(Window w) => $$"""
|
||||
ContainerAppConsoleLogs_CL
|
||||
| where TimeGenerated >= {{w.From}} and TimeGenerated < {{w.To}}
|
||||
| where Log_s startswith '{{ToolCallLinePrefix}}'
|
||||
| extend d = parse_json(Log_s)
|
||||
{{(w.IncludePlatformAdmins ? string.Empty : "| where tostring(d.scope) != 'ga'")}}
|
||||
| summarize calls = count(), errors = countif(tobool(d.isError)), cancelled = countif(tobool(d.cancelled)), overCap = countif(tobool(d.overCap)), p95Ms = percentile(tolong(d.durationMs), 95), avgChars = avg(todouble(d.resultChars)) by tool = tostring(d.tool)
|
||||
""";
|
||||
|
||||
/// <summary>The MCP server's per-account refusals (its own per-minute limit), per account.</summary>
|
||||
internal static string McpRefusals(Window w) => $$"""
|
||||
ContainerAppConsoleLogs_CL
|
||||
| where TimeGenerated >= {{w.From}} and TimeGenerated < {{w.To}}
|
||||
| where Log_s startswith '{{McpRefusalLinePrefix}}'
|
||||
| extend principal = tolower(extract(@'key=(\S+)', 1, Log_s))
|
||||
| summarize refused = count(), lastAt = max(TimeGenerated) by principal
|
||||
""";
|
||||
|
||||
/// <summary>How far back the MCP log reaches inside the window, and the per-account limit of the newest start line.</summary>
|
||||
internal static string McpServerFacts(Window w) => $$"""
|
||||
let lines = ContainerAppConsoleLogs_CL
|
||||
| where TimeGenerated >= {{w.From}} and TimeGenerated < {{w.To}};
|
||||
let oldest = toscalar(lines | summarize min(TimeGenerated));
|
||||
let bootLimit = toscalar(lines | where Log_s startswith '{{BootLinePrefix}}' | top 1 by TimeGenerated desc | project toint(parse_json(Log_s).rateLimitPerMinute));
|
||||
print oldest = oldest, rateLimitPerMinute = bootLimit
|
||||
""";
|
||||
|
||||
// ── Parsed rows ────────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record CallRow(string Day, string Tool, string Tenant, string Principal, long Calls, long ServedCalls, long Requests, DateTime? LastAt, string Role);
|
||||
internal sealed record RouteRow(string Tool, string Route, long Requests);
|
||||
internal sealed record RefusalRow(string Gate, string Day, string Tenant, string Principal, string Level, string Scope, string Plan,
|
||||
long Refused, DateTime FirstAt, DateTime LastAt, long? Used, long? Limit);
|
||||
internal sealed record PeakRow(string Metric, string Tenant, string Principal, long Peak, DateTime? PeakAt);
|
||||
internal sealed record ToolQualityRow(string Tool, long Calls, long Errors, long Cancelled, long OverCap, long? P95Ms, double? AvgChars);
|
||||
internal sealed record McpRefusalRow(string Principal, long Refused, DateTime? LastAt);
|
||||
internal sealed record ServerFacts(DateTime? Oldest, int? RateLimitPerMinute);
|
||||
|
||||
internal static List<CallRow> ParseCalls(KqlTable t) => Read(t, r => new CallRow(
|
||||
r.Str("day"), r.Str("tool"), r.Str("tenant"), r.Str("principal"),
|
||||
r.Long("calls"), r.Long("servedCalls"), r.Long("charged"), r.Date("firstAt"), r.Str("role")));
|
||||
|
||||
internal static List<RouteRow> ParseRoutes(KqlTable t) => Read(t, r => new RouteRow(r.Str("tool"), r.Str("route"), r.Long("charged")));
|
||||
|
||||
internal static List<RefusalRow> ParseRefusals(KqlTable t) => Read(t, r => new RefusalRow(
|
||||
r.Str("gate"), r.Str("day"), r.Str("tenant"), r.Str("principal"), r.Str("level"), r.Str("scope"), r.Str("plan"),
|
||||
r.Long("refused"), r.Date("firstAt") ?? DateTime.MinValue, r.Date("lastAt") ?? DateTime.MinValue, r.LongOrNull("usedCount"), r.LongOrNull("limitValue")));
|
||||
|
||||
internal static List<PeakRow> ParsePeaks(KqlTable t) => Read(t, r => new PeakRow(
|
||||
r.Str("metric"), r.Str("tenant"), r.Str("principal"), r.Long("peak"), r.Date("peakAt")));
|
||||
|
||||
internal static List<ToolQualityRow> ParseToolQuality(KqlTable t) => Read(t, r => new ToolQualityRow(
|
||||
r.Str("tool"), r.Long("calls"), r.Long("errors"), r.Long("cancelled"), r.Long("overCap"), r.LongOrNull("p95Ms"), r.DoubleOrNull("avgChars")));
|
||||
|
||||
internal static List<McpRefusalRow> ParseMcpRefusals(KqlTable t) => Read(t, r => new McpRefusalRow(r.Str("principal"), r.Long("refused"), r.Date("lastAt")));
|
||||
|
||||
internal static ServerFacts ParseServerFacts(KqlTable t)
|
||||
{
|
||||
var rows = Read(t, r => new ServerFacts(r.Date("oldest"), (int?)r.LongOrNull("rateLimitPerMinute")));
|
||||
return rows.Count > 0 ? rows[0] : new ServerFacts(null, null);
|
||||
}
|
||||
|
||||
private static List<T> Read<T>(KqlTable table, Func<RowReader, T> map)
|
||||
{
|
||||
var index = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < table.Columns.Count; i++)
|
||||
index[table.Columns[i].Name] = i;
|
||||
|
||||
var result = new List<T>(table.Rows.Count);
|
||||
foreach (var row in table.Rows)
|
||||
result.Add(map(new RowReader(index, row)));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Typed access to one Kusto row by column name; a missing column or a null cell reads as empty.</summary>
|
||||
private readonly struct RowReader
|
||||
{
|
||||
private readonly Dictionary<string, int> _index;
|
||||
private readonly IReadOnlyList<JsonElement> _cells;
|
||||
|
||||
public RowReader(Dictionary<string, int> index, IReadOnlyList<JsonElement> cells)
|
||||
{
|
||||
_index = index;
|
||||
_cells = cells;
|
||||
}
|
||||
|
||||
private JsonElement? Cell(string column)
|
||||
=> _index.TryGetValue(column, out var i) && i < _cells.Count && _cells[i].ValueKind != JsonValueKind.Null
|
||||
? _cells[i]
|
||||
: null;
|
||||
|
||||
public string Str(string column) => Cell(column) is { ValueKind: JsonValueKind.String } c ? c.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
public long Long(string column) => LongOrNull(column) ?? 0;
|
||||
|
||||
public long? LongOrNull(string column) => Cell(column) switch
|
||||
{
|
||||
{ ValueKind: JsonValueKind.Number } c when c.TryGetInt64(out var l) => l,
|
||||
{ ValueKind: JsonValueKind.Number } c => (long)Math.Round(c.GetDouble()),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public double? DoubleOrNull(string column) => Cell(column) is { ValueKind: JsonValueKind.Number } c ? c.GetDouble() : null;
|
||||
|
||||
public DateTime? Date(string column)
|
||||
=> Cell(column) is { ValueKind: JsonValueKind.String } c
|
||||
&& DateTime.TryParse(c.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var d)
|
||||
? DateTime.SpecifyKind(d, DateTimeKind.Utc)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -1072,6 +1072,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
tenantId, "System.RuleEngine",
|
||||
new { ruleId, reason, catalogRule = isCatalogRule, endsRun, sessionId, runKind, skippedRules });
|
||||
|
||||
/// <summary>
|
||||
/// An MCP caller ran into a daily or monthly request quota of its home tenant (<paramref name="level"/>
|
||||
/// <c>user</c> = the account's own window, <c>tenant</c> = the organization's) and was answered 429.
|
||||
/// <c>McpQuotaEnforcementMiddleware</c> raises it for the first refusal per instance, account or
|
||||
/// organization, window and period; every refusal is also in the Warning trace.
|
||||
/// </summary>
|
||||
public Task RecordMcpQuotaExceededAsync(
|
||||
string tenantId, string principal, string level, string scope, string plan, long limit, long used, DateTime resetUtc)
|
||||
=> WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.McpQuotaExceeded, OpsEventSeverity.Info,
|
||||
level == McpQuotaLevel.Tenant
|
||||
? $"MCP {scope} request quota of the organization reached ({used}/{limit}, plan '{plan}'); its members get 429 until {resetUtc:yyyy-MM-ddTHH:mm:ss}Z"
|
||||
: $"MCP {scope} request quota of {principal} reached ({used}/{limit}, plan '{plan}'); 429 until {resetUtc:yyyy-MM-ddTHH:mm:ss}Z",
|
||||
tenantId, principal,
|
||||
new { level, scope, plan, limit, used, resetUtc = $"{resetUtc:yyyy-MM-ddTHH:mm:ss}Z" });
|
||||
|
||||
/// <summary>
|
||||
/// App-version duration regression radar: an app's newest version installs with a median
|
||||
/// duration ≥2× (and ≥5 min absolute) over the previous version's median. Fired ONCE per
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Stamps every telemetry item a view-as request emits (customEvents, traces, dependencies) with the viewed
|
||||
/// tenant, so KQL can leave a Global Admin's read-only "view as" out of that tenant's activity. Handlers put
|
||||
/// the viewed tenant on their own events; this marks them as not the tenant's doing. The request row is
|
||||
/// stamped by RequestTelemetryMiddleware, which runs outside the scope <see cref="Enter"/> opens.
|
||||
/// </summary>
|
||||
public sealed class ViewAsTelemetryProcessor : ITelemetryProcessor
|
||||
{
|
||||
private static readonly AsyncLocal<string?> ViewedTenant = new();
|
||||
|
||||
private readonly ITelemetryProcessor _next;
|
||||
|
||||
public ViewAsTelemetryProcessor(ITelemetryProcessor next) => _next = next;
|
||||
|
||||
/// <summary>Marks the rest of the current async flow — one admitted view-as request — as viewing this tenant.</summary>
|
||||
public static void Enter(string viewedTenantId) => ViewedTenant.Value = viewedTenantId;
|
||||
|
||||
public void Process(ITelemetry item)
|
||||
{
|
||||
Stamp(item, ViewedTenant.Value);
|
||||
_next.Process(item);
|
||||
}
|
||||
|
||||
internal static void Stamp(ITelemetry item, string? viewedTenantId)
|
||||
{
|
||||
if (viewedTenantId != null && item is ISupportProperties withProperties)
|
||||
withProperties.Properties.TryAdd(RequestRowDimensions.ViewAsTenantId, viewedTenantId);
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -16,7 +16,7 @@
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.13",
|
||||
"jose": "^6.2.14",
|
||||
"undici": "^8.12.3",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
@@ -30,7 +30,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^9.39.5",
|
||||
"knip": "^6.43.0",
|
||||
"knip": "^6.44.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
@@ -4289,9 +4289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.13",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz",
|
||||
"integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==",
|
||||
"version": "6.2.14",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.14.tgz",
|
||||
"integrity": "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
@@ -4351,9 +4351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.43.0.tgz",
|
||||
"integrity": "sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==",
|
||||
"version": "6.44.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
|
||||
"integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.13",
|
||||
"jose": "^6.2.14",
|
||||
"undici": "^8.12.3",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
@@ -40,7 +40,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^9.39.5",
|
||||
"knip": "^6.43.0",
|
||||
"knip": "^6.44.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
|
||||
@@ -12,10 +12,19 @@
|
||||
* No live backend or real token is needed: fetch is stubbed and JWTs are
|
||||
* hand-built (the proxy does not verify signatures — the backend does).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// RATE_LIMIT is read from the environment at module import. Set a low cap so
|
||||
/** The refusal line start the backend's usage overview matches (tests/fixtures/mcp-log-lines). */
|
||||
function refusalLinePrefix(): string {
|
||||
const contract = JSON.parse(
|
||||
readFileSync(new URL('../../../../../tests/fixtures/mcp-log-lines/fields.json', import.meta.url), 'utf8'),
|
||||
) as { prefixes: { mcp_refusal: string } };
|
||||
return contract.prefixes.mcp_refusal;
|
||||
}
|
||||
|
||||
// RATE_LIMIT_PER_MINUTE is read from the environment at module import. Set a low cap so
|
||||
// the 429 path is cheap to drive, BEFORE the dynamic import resolves.
|
||||
process.env.MCP_RATE_LIMIT_PER_MINUTE = '2';
|
||||
|
||||
@@ -155,6 +164,7 @@ function uniqueUpn(): string {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('accessGuard — 401 (authentication failures)', () => {
|
||||
@@ -387,6 +397,7 @@ describe('accessGuard — 429 (rate limit) and caching', () => {
|
||||
const upn = uniqueUpn();
|
||||
const token = validToken(upn);
|
||||
stubBackend({ body: { allowed: true } });
|
||||
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const first = await runGuard(mockReq(`Bearer ${token}`));
|
||||
const second = await runGuard(mockReq(`Bearer ${token}`));
|
||||
@@ -397,6 +408,9 @@ describe('accessGuard — 429 (rate limit) and caching', () => {
|
||||
expect(third.nextCalled).toBe(false);
|
||||
expect(third.status).toBe(429);
|
||||
expect((third.body as { retryAfterSeconds: number }).retryAfterSeconds).toBe(60);
|
||||
// The GA usage overview counts these refusals by the line start and the key (tests/fixtures/mcp-log-lines).
|
||||
const refusal = logSpy.mock.calls.map((call) => String(call[0])).find((line) => line.startsWith('[mcp-auth] 429'));
|
||||
expect(refusal?.startsWith(`${refusalLinePrefix()}key=${upn} `)).toBe(true);
|
||||
});
|
||||
|
||||
it('serves the cached access decision (one backend call across repeat requests)', async () => {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* get_api_usage: the shaping of the MCP usage views (api-usage-shape.ts) and the tool's routing.
|
||||
* - the summary cuts every list to its head and states the full length; nothing is cut silently;
|
||||
* - absent wire values become explicit nulls (calls without request telemetry, unreadable counters);
|
||||
* - tenants are named on refusal and minute rows; an account's counter keys fold into tool and day sums;
|
||||
* - organization and account need their id; a UPN is not an object id.
|
||||
*
|
||||
* apiFetch is mocked so these run with no backend / token.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { apiFetchMock } = vi.hoisted(() => ({ apiFetchMock: vi.fn() }));
|
||||
|
||||
vi.mock('../client.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../client.js')>();
|
||||
return { ...actual, apiFetch: apiFetchMock };
|
||||
});
|
||||
|
||||
import { registerAdminTools } from '../tools/admin.js';
|
||||
import { ObjectIdSchema } from '../tools/shared.js';
|
||||
import { NO_TOOL, SUMMARY_HEAD, shapeAccount, shapeOrganization, shapeOverview, usageWindow } from '../api-usage-shape.js';
|
||||
import type {
|
||||
GetGlobalMcpUsageOverviewResponse,
|
||||
GetMcpOrganizationUsageResponse,
|
||||
GetMcpUserUsageResponse,
|
||||
McpUsageAccountItem,
|
||||
McpUsageMinuteItem,
|
||||
McpUsageQuotaHitItem,
|
||||
McpUsageTenantItem,
|
||||
McpUsageToolItem,
|
||||
} from '../generated/wire-types.generated.js';
|
||||
|
||||
const TENANT_A = 'aaaaaaaa-0000-4000-8000-00000000000a';
|
||||
const TENANT_B = 'bbbbbbbb-0000-4000-8000-00000000000b';
|
||||
const OID = '11111111-2222-4333-8444-555555555555';
|
||||
const AT = '2026-11-05T10:00:00Z';
|
||||
|
||||
function account(principal: string, over: Partial<McpUsageAccountItem> = {}): McpUsageAccountItem {
|
||||
return {
|
||||
userId: OID,
|
||||
principal,
|
||||
isPlatformAdmin: false,
|
||||
quota: { plan: 'community', dailyLimit: 50, monthlyLimit: 250, dailyUsed: 12, monthlyUsed: 40 },
|
||||
calls: 5,
|
||||
requests: 12,
|
||||
quotaHits: 0,
|
||||
topTools: [{ tool: 'get_session_summary', calls: 3 }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function tenant(tenantId: string, domainName: string | undefined, accounts: McpUsageAccountItem[]): McpUsageTenantItem {
|
||||
return {
|
||||
tenantId,
|
||||
...(domainName ? { domainName } : {}),
|
||||
quota: { tenantPlan: 'community', dailyLimit: 100, monthlyLimit: 250, dailyUsed: 30, monthlyUsed: 90 },
|
||||
calls: accounts.reduce((s, a) => s + a.calls, 0),
|
||||
requests: accounts.reduce((s, a) => s + a.requests, 0),
|
||||
quotaHits: 1,
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
|
||||
const tool = (name: string, calls: number, over: Partial<McpUsageToolItem> = {}): McpUsageToolItem =>
|
||||
({ tool: name, calls, accounts: 2, tenants: 1, requestsPerCall: 4.5, errorRate: 0.1, p95DurationMs: 900, routes: [{ route: 'GET sessions/{id}', requests: 9 }], ...over });
|
||||
|
||||
const hit = (tenantId: string, principal: string): McpUsageQuotaHitItem =>
|
||||
({ date: '20261105', tenantId, principal, level: 'user', scope: 'daily', plan: 'community', limit: 50, used: 50, refused: 3, firstAt: AT, lastAt: AT });
|
||||
|
||||
const minute = (tenantId: string, principal: string, sharePercent: number): McpUsageMinuteItem =>
|
||||
({ tenantId, principal, sharePercent, peakCalls: 30, peakCallsAt: AT, mcpRefused: 0, peakRequests: 60, peakRequestsAt: AT, backendLimit: 120, backendRefused: 0 });
|
||||
|
||||
function overview(over: Partial<GetGlobalMcpUsageOverviewResponse> = {}): GetGlobalMcpUsageOverviewResponse {
|
||||
return {
|
||||
dateFrom: '20261007',
|
||||
dateTo: '20261105',
|
||||
includesPlatformAdmins: false,
|
||||
telemetry: { requestsAvailable: true, mcpLogAvailable: true },
|
||||
totals: {
|
||||
calls: 40, mcpOnlyCalls: 4, tools: 2, requests: 24, tenants: 2, accounts: 2, servicePrincipals: 0,
|
||||
quotaHitAccounts: 1, minuteRefusedAccounts: 0, limitAccounts: 1,
|
||||
},
|
||||
daily: [{ date: '20261105', calls: 40, requests: 24, accounts: 2, callsByPlan: [{ plan: 'community', calls: 40 }] }],
|
||||
tenants: [tenant(TENANT_A, 'contoso.com', [account('alice@contoso.com')]), tenant(TENANT_B, undefined, [account('bob@fabrikam.com')])],
|
||||
tools: [tool('get_session_summary', 36), tool('search_docs', 4, { accounts: undefined, tenants: undefined, requestsPerCall: 0, routes: [] })],
|
||||
quotaHits: [hit(TENANT_A, 'alice@contoso.com')],
|
||||
minuteLimits: { mcpServerLimit: 60, accounts: [minute(TENANT_A, 'alice@contoso.com', 50), minute(TENANT_B, 'bob@fabrikam.com', 25)] },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('usageWindow', () => {
|
||||
it('is the trailing window of whole UTC days ending today, as yyyyMMdd', () => {
|
||||
const now = new Date('2026-11-05T23:30:00Z');
|
||||
expect(usageWindow(1, now)).toEqual({ from: '20261105', to: '20261105' });
|
||||
expect(usageWindow(30, now)).toEqual({ from: '20261007', to: '20261105' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapeOverview — summary', () => {
|
||||
it('carries the backend totals and every list head with its full length', () => {
|
||||
const out = shapeOverview(overview(), 'summary');
|
||||
|
||||
expect(out.view).toBe('summary');
|
||||
expect(out.window).toEqual({ from: '20261007', to: '20261105' });
|
||||
expect(out.telemetry).toEqual({ requestsAvailable: true, mcpLogAvailable: true, mcpLogFrom: null, partial: false });
|
||||
expect(out.totals).toMatchObject({ calls: 40, mcpOnlyCalls: 4, requests: 24, limitAccounts: 1 });
|
||||
expect(out.lists).toEqual({ tools: 2, tenants: 2, quotaHits: 1, minuteLimits: 2 });
|
||||
expect(out).not.toHaveProperty('note'); // nothing was cut
|
||||
|
||||
const [summaryTool, docs] = out.tools as Array<Record<string, unknown>>;
|
||||
expect(summaryTool).toEqual({ tool: 'get_session_summary', calls: 36, requestsPerCall: 4.5, accounts: 2, tenants: 1, errorRate: 0.1, p95DurationMs: 900 });
|
||||
expect(docs).toMatchObject({ tool: 'search_docs', accounts: null, tenants: null }); // runs in the MCP server alone
|
||||
|
||||
const [contoso, unnamed] = out.tenants as Array<Record<string, unknown>>;
|
||||
expect(contoso).toMatchObject({ domainName: 'contoso.com', plan: 'community', accounts: 1, today: { used: 30, limit: 100 }, month: { used: 90, limit: 250 } });
|
||||
expect(unnamed.domainName).toBeNull();
|
||||
|
||||
expect((out.quotaHits as Array<Record<string, unknown>>)[0]).toMatchObject({ domainName: 'contoso.com', principal: 'alice@contoso.com', used: 50, limit: 50 });
|
||||
const minuteLimits = out.minuteLimits as { mcpServerLimit: number; accounts: Array<Record<string, unknown>> };
|
||||
expect(minuteLimits.mcpServerLimit).toBe(60);
|
||||
expect(minuteLimits.accounts.map((m) => [m.principal, m.domainName, m.sharePercent])).toEqual([
|
||||
['alice@contoso.com', 'contoso.com', 50],
|
||||
['bob@fabrikam.com', null, 25],
|
||||
]);
|
||||
});
|
||||
|
||||
it('cuts a long list to its head and says so', () => {
|
||||
const tools = Array.from({ length: SUMMARY_HEAD.tools + 3 }, (_, i) => tool(`tool_${i}`, 100 - i));
|
||||
const out = shapeOverview(overview({ tools }), 'summary');
|
||||
|
||||
expect(out.tools).toHaveLength(SUMMARY_HEAD.tools);
|
||||
expect((out.lists as Record<string, number>).tools).toBe(SUMMARY_HEAD.tools + 3);
|
||||
expect(out.note).toMatch(/cut to their first rows/);
|
||||
});
|
||||
|
||||
it('shows calls as null, not 0, when the request telemetry did not answer', () => {
|
||||
const out = shapeOverview(overview({
|
||||
telemetry: { requestsAvailable: false, mcpLogAvailable: false, mcpLogFrom: '20261020', partial: true },
|
||||
totals: { tools: 0, requests: 24, tenants: 2, accounts: 2, servicePrincipals: 0, quotaHitAccounts: 0, minuteRefusedAccounts: 0, limitAccounts: 0 },
|
||||
}), 'summary');
|
||||
|
||||
expect(out.totals).toMatchObject({ calls: null, mcpOnlyCalls: null, requests: 24 });
|
||||
expect(out.telemetry).toEqual({ requestsAvailable: false, mcpLogAvailable: false, mcpLogFrom: '20261020', partial: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapeOverview — one list in full', () => {
|
||||
it('tenants carry every account with explicit windows; an unreadable counter is null', () => {
|
||||
const unreadable = account('carol@contoso.com', { quota: { plan: 'community', dailyLimit: 50, monthlyLimit: 250, dailyUsed: -1, monthlyUsed: -1 } });
|
||||
const out = shapeOverview(overview({ tenants: [tenant(TENANT_A, 'contoso.com', [account('alice@contoso.com'), unreadable])] }), 'tenants');
|
||||
|
||||
const [only] = out.tenants as Array<{ accounts: Array<Record<string, unknown>> }>;
|
||||
expect(only.accounts.map((a) => a.principal)).toEqual(['alice@contoso.com', 'carol@contoso.com']);
|
||||
expect(only.accounts[0]).toMatchObject({ plan: 'community', today: { used: 12, limit: 50 }, month: { used: 40, limit: 250 }, role: null });
|
||||
expect(only.accounts[1]).toMatchObject({ today: { used: null, limit: 50 }, month: { used: null, limit: 250 } });
|
||||
});
|
||||
|
||||
it('tools keep their routes and quality figures; the other views return their one list', () => {
|
||||
const data = overview();
|
||||
const tools = shapeOverview(data, 'tools').tools as Array<Record<string, unknown>>;
|
||||
expect(tools[0]).toMatchObject({ routes: [{ route: 'GET sessions/{id}', requests: 9 }], avgResultChars: null, overCapCalls: null });
|
||||
|
||||
expect(shapeOverview(data, 'quotaHits').quotaHits).toHaveLength(1);
|
||||
expect(shapeOverview(data, 'minuteLimits')).toMatchObject({ mcpServerLimit: 60 });
|
||||
expect(shapeOverview(data, 'daily').daily).toEqual(data.daily);
|
||||
for (const view of ['tools', 'tenants', 'quotaHits', 'minuteLimits', 'daily'] as const) {
|
||||
expect(shapeOverview(data, view)).not.toHaveProperty('totals');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapeOrganization', () => {
|
||||
it('names the windows and sums the accounts of the range', () => {
|
||||
const data: GetMcpOrganizationUsageResponse = {
|
||||
tenantId: TENANT_A,
|
||||
dateFrom: '20261101',
|
||||
dateTo: '20261105',
|
||||
users: [
|
||||
{ userId: OID, userPrincipalName: 'alice@contoso.com', requestsToday: 10, requestsThisMonth: 40, requestsInRange: 40, lastRequestAt: AT },
|
||||
{ userId: 'oid-2', requestsToday: 0, requestsThisMonth: 2, requestsInRange: 2 },
|
||||
],
|
||||
quota: { tenantPlan: 'community', dailyLimit: 100, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 42 },
|
||||
daily: [{ date: '20261105', requests: 10 }],
|
||||
};
|
||||
|
||||
const out = shapeOrganization(data);
|
||||
expect(out).toMatchObject({ view: 'organization', plan: 'community', today: { used: 10, limit: 100 }, month: { used: 42, limit: 250 }, requests: 42 });
|
||||
expect((out.accounts as Array<Record<string, unknown>>)[1]).toEqual(
|
||||
{ principal: null, userId: 'oid-2', requestsInRange: 2, requestsToday: 0, requestsThisMonth: 2, lastRequestAt: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapeAccount', () => {
|
||||
it('folds the counter rows into tools with their paths and into days', () => {
|
||||
const row = (date: string, endpoint: string, requestCount: number, lastRequestAt = AT) =>
|
||||
({ userId: OID, userPrincipalName: 'alice@contoso.com', tenantId: TENANT_A, endpoint, date, requestCount, lastRequestAt });
|
||||
const data: GetMcpUserUsageResponse = {
|
||||
userId: OID,
|
||||
records: [
|
||||
row('20261104', 'get_session_summary:sessions/{id}', 6),
|
||||
row('20261105', 'get_session_summary:sessions/{id}/events', 9),
|
||||
row('20261105', 'get_session_summary:sessions/{id}', 3),
|
||||
row('20261105', 'auth/me', 1),
|
||||
],
|
||||
};
|
||||
|
||||
const out = shapeAccount(data, { from: '20261101', to: '20261105' });
|
||||
expect(out).toMatchObject({ userId: OID, principal: 'alice@contoso.com', tenantId: TENANT_A, requests: 19 });
|
||||
expect(out.byTool).toEqual([
|
||||
{ tool: 'get_session_summary', requests: 18, paths: [{ path: 'sessions/{id}', requests: 9 }, { path: 'sessions/{id}/events', requests: 9 }] },
|
||||
{ tool: NO_TOOL, requests: 1, paths: [{ path: 'auth/me', requests: 1 }] },
|
||||
]);
|
||||
expect(out.byDay).toEqual([{ date: '20261104', requests: 6 }, { date: '20261105', requests: 13 }]);
|
||||
});
|
||||
|
||||
it('answers an account without rows with zeros, not an error', () => {
|
||||
expect(shapeAccount({ userId: OID, records: [] }, { from: '20261105', to: '20261105' }))
|
||||
.toEqual({ view: 'account', userId: OID, principal: null, tenantId: null, window: { from: '20261105', to: '20261105' }, requests: 0, byTool: [], byDay: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ── The tool ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
|
||||
|
||||
function handler(): Handler {
|
||||
const handlers: Record<string, Handler> = {};
|
||||
const fake = { registerTool: (name: string, _def: unknown, h: Handler) => { handlers[name] = h; } };
|
||||
registerAdminTools(fake as never, true, true, false);
|
||||
return handlers.get_api_usage;
|
||||
}
|
||||
|
||||
describe('get_api_usage — routing', () => {
|
||||
beforeEach(() => apiFetchMock.mockReset());
|
||||
|
||||
it('reads the overview for the overview views, with the platform-admin switch and a long timeout', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce(overview());
|
||||
const result = await handler()({ view: 'summary', days: 7, includePlatformAdmins: true });
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
const [path, init] = apiFetchMock.mock.calls[0] as [string, { signal?: AbortSignal }];
|
||||
expect(path).toBe('/api/global/metrics/mcp-usage/overview?days=7&platformAdmins=include');
|
||||
expect(init.signal).toBeDefined();
|
||||
expect(JSON.parse(result.content[0].text).view).toBe('summary');
|
||||
});
|
||||
|
||||
it('reads one tenant and one account over the trailing window', async () => {
|
||||
apiFetchMock.mockResolvedValue({ tenantId: TENANT_A, dateFrom: '20261105', dateTo: '20261105', users: [], quota: { tenantPlan: 'pro', dailyLimit: 0, monthlyLimit: 0, dailyUsed: 0, monthlyUsed: 0 }, daily: [] });
|
||||
await handler()({ view: 'organization', days: 1, tenantId: TENANT_A });
|
||||
expect(apiFetchMock.mock.calls[0][0]).toMatch(new RegExp(`^/api/global/metrics/mcp-usage/organization\\?tenantId=${TENANT_A}&dateFrom=\\d{8}&dateTo=\\d{8}$`));
|
||||
|
||||
apiFetchMock.mockResolvedValue({ userId: OID, records: [] });
|
||||
await handler()({ view: 'account', days: 1, userId: OID });
|
||||
expect(apiFetchMock.mock.calls[1][0]).toMatch(new RegExp(`^/api/metrics/mcp-usage/user/${OID}\\?dateFrom=\\d{8}&dateTo=\\d{8}$`));
|
||||
});
|
||||
|
||||
it('refuses organization without tenantId and account without userId, before any request', async () => {
|
||||
const noTenant = await handler()({ view: 'organization', days: 30 });
|
||||
const noUser = await handler()({ view: 'account', days: 30 });
|
||||
|
||||
expect(noTenant.isError).toBe(true);
|
||||
expect(noTenant.content[0].text).toMatch(/tenantId is required for view=organization/);
|
||||
expect(noUser.isError).toBe(true);
|
||||
expect(noUser.content[0].text).toMatch(/userId .* is required for view=account/);
|
||||
expect(apiFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a UPN is not an object id', () => {
|
||||
expect(ObjectIdSchema.safeParse(OID).success).toBe(true);
|
||||
expect(ObjectIdSchema.safeParse('alice@contoso.com').success).toBe(false);
|
||||
expect(ObjectIdSchema.safeParse('../global/metrics').success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,9 @@ interface Role {
|
||||
const ROLES: Role[] = [
|
||||
// Raised 2026-10-28 by +1,100 each: the platform-only get_growth_metrics (measured 1,106 chars).
|
||||
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 93_100 },
|
||||
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_100 },
|
||||
// Raised 2026-11-05 by +200: get_api_usage reads the MCP usage overview — eight views, the window, the
|
||||
// platform-admin switch and two drill-down ids (measured 78,205 chars; the Global Admin catalog stayed under).
|
||||
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_300 },
|
||||
// Raised 2026-11-04 by +400: search_sessions gained the groupTag filter and five Autopilot
|
||||
// projection keys (measured 53,318 chars; the other roles stayed under their ceilings).
|
||||
{ label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin', maxCatalogChars: 53_400 },
|
||||
@@ -56,8 +58,9 @@ const ROLES: Role[] = [
|
||||
* Shared-mechanics arguments should read the same everywhere (shared.ts helpers); each distinct
|
||||
* wording is one more contract the model has to reconcile. Ratchet on the variant count in the
|
||||
* Global Admin catalog (2026-09-22 post-diet baseline; pre-diet: tenantId 28, pageSize 14, continuation 10).
|
||||
* days 10 since 2026-11-05: get_api_usage's window is bounded by the 90 days the request telemetry keeps.
|
||||
*/
|
||||
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 9 };
|
||||
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 10 };
|
||||
|
||||
/**
|
||||
* Domains a description may name. Anything else that looks like a domain is treated as a
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* sessions; the cap and a truncated flag are now surfaced.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateUsage, paginateInventory } from '../tools/admin.js';
|
||||
import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateInventory } from '../tools/admin.js';
|
||||
|
||||
describe('inferGeoGroupBy', () => {
|
||||
it('treats a single-segment key as country', () => {
|
||||
@@ -90,51 +90,6 @@ describe('platformWindowEcho', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('paginateUsage', () => {
|
||||
const records = Array.from({ length: 5 }, (_, i) => ({ endpoint: `e${i}`, count: i }));
|
||||
|
||||
it('pages the global per-record breakdown and emits a nextLink while more remain', () => {
|
||||
const out = paginateUsage({ tenantId: 't1', records }, 2);
|
||||
expect(out.tenantId).toBe('t1'); // top-level fields echoed verbatim
|
||||
expect(out.totalCount).toBe(5);
|
||||
expect(out.count).toBe(2);
|
||||
expect(out.offset).toBe(0);
|
||||
expect(out.records).toEqual(records.slice(0, 2));
|
||||
expect(out.nextLink).toBe('usage-offset:2');
|
||||
});
|
||||
|
||||
it('follows a continuation cursor to the next slice', () => {
|
||||
const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:2');
|
||||
expect(out.offset).toBe(2);
|
||||
expect(out.records).toEqual(records.slice(2, 4));
|
||||
expect(out.nextLink).toBe('usage-offset:4');
|
||||
});
|
||||
|
||||
it('drops nextLink on the final page', () => {
|
||||
const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:4');
|
||||
expect(out.count).toBe(1);
|
||||
expect(out.records).toEqual(records.slice(4));
|
||||
expect(out.nextLink).toBeNull();
|
||||
});
|
||||
|
||||
it('pages the daily "summaries" array too, preserving the key', () => {
|
||||
const summaries = [{ date: '2026-06-01' }, { date: '2026-06-02' }];
|
||||
const out = paginateUsage({ tenantId: 't1', summaries }, 1);
|
||||
expect(out.summaries).toEqual(summaries.slice(0, 1));
|
||||
expect(Object.prototype.hasOwnProperty.call(out, 'records')).toBe(false);
|
||||
expect(out.nextLink).toBe('usage-offset:1');
|
||||
});
|
||||
|
||||
it('passes an unexpected shape through untouched', () => {
|
||||
const weird = { tenantId: 't1', somethingElse: 42 };
|
||||
expect(paginateUsage(weird, 200)).toEqual(weird);
|
||||
});
|
||||
|
||||
it('handles a null/non-object payload defensively', () => {
|
||||
expect(paginateUsage(null, 200)).toEqual({ records: [], totalCount: 0, count: 0, offset: 0, nextLink: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('paginateInventory', () => {
|
||||
const inventory = Array.from({ length: 5 }, (_, i) => ({ normalizedName: `app-${i}`, cpeUri: '' }));
|
||||
const payload = { success: true, tenantId: 't1', total: 5, matched: 0, unmatched: 5, inventory };
|
||||
|
||||
@@ -327,3 +327,19 @@ describe('attachToolCallRejectionSniffer', () => {
|
||||
expect(res.end).toBe(originalEnd);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootLine', () => {
|
||||
it('starts with the type marker and carries the enforced per-account budget', async () => {
|
||||
// The GA usage page finds the line by its start and reads rateLimitPerMinute from the newest one.
|
||||
const { bootLine } = await loadTelemetry(false);
|
||||
const line = bootLine(
|
||||
{ readyMs: 812, version: '1.2.3', commit: 'abc1234', docsCommit: 'def5678', rateLimitPerMinute: 60 },
|
||||
new Date('2026-11-05T12:00:00.000Z'),
|
||||
);
|
||||
expect(line.startsWith('{"type":"boot"')).toBe(true);
|
||||
expect(JSON.parse(line)).toEqual({
|
||||
type: 'boot', readyMs: 812, version: '1.2.3', commit: 'abc1234', docsCommit: 'def5678',
|
||||
rateLimitPerMinute: 60, timestamp: '2026-11-05T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,19 +105,13 @@ suite('get_agent_efficiency_metrics', () => {
|
||||
});
|
||||
|
||||
suite('get_api_usage', () => {
|
||||
it('should return daily usage summary', async () => {
|
||||
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/daily`);
|
||||
expect(Array.isArray(data.summaries)).toBe(true);
|
||||
if (data.summaries.length > 0) {
|
||||
expect(data.summaries[0]).toHaveProperty('date');
|
||||
expect(data.summaries[0]).toHaveProperty('totalRequests');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return global per-record usage', async () => {
|
||||
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage`);
|
||||
// Response structure depends on implementation, just verify no error
|
||||
expect(data).toBeDefined();
|
||||
it('should return the all-tenant overview with its totals', async () => {
|
||||
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/overview?days=7`);
|
||||
expect(data).toHaveProperty('totals');
|
||||
expect(typeof data.totals.requests).toBe('number');
|
||||
expect(Array.isArray(data.tenants)).toBe(true);
|
||||
expect(Array.isArray(data.tools)).toBe(true);
|
||||
expect(Array.isArray(data.minuteLimits.accounts)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The backend's GA MCP usage overview reads three kinds of line from this server's console log with KQL:
|
||||
* the tool_call line, the boot line and the access guard's per-account refusal. tests/fixtures/mcp-log-lines
|
||||
* names the line starts and fields it reads; this suite proves the server writes every one of them (the
|
||||
* backend suite proves its queries read nothing else). A rename here would otherwise empty a column there.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
interface LogLineContract {
|
||||
prefixes: { tool_call: string; boot: string; mcp_refusal: string };
|
||||
fields: { tool_call: string[]; boot: string[] };
|
||||
}
|
||||
|
||||
const contract = JSON.parse(
|
||||
readFileSync(new URL('../../../../../tests/fixtures/mcp-log-lines/fields.json', import.meta.url), 'utf8'),
|
||||
) as LogLineContract;
|
||||
|
||||
async function loadTelemetry() {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('MCP_TOOL_LOGGING', 'true');
|
||||
return await import('../telemetry.js');
|
||||
}
|
||||
|
||||
function loggedLines(spy: ReturnType<typeof vi.spyOn>): string[] {
|
||||
return spy.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('MCP log lines the usage overview reads', () => {
|
||||
it('writes the tool_call line with its type first and every field the overview reads', async () => {
|
||||
const { withToolTelemetry } = await loadTelemetry();
|
||||
const { runWithCaller } = await import('../client.js');
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// A completed call carries every field but `cancelled`, which only an abandoned call writes.
|
||||
await withToolTelemetry('get_session', { sessionId: 's' }, () => ({ content: [{ type: 'text', text: '{}' }] }));
|
||||
const controller = new AbortController();
|
||||
await runWithCaller({ token: 't', isGlobalAdmin: false, signal: controller.signal }, () =>
|
||||
withToolTelemetry('get_session', { sessionId: 's' }, () => {
|
||||
controller.abort();
|
||||
return { isError: true, content: [{ type: 'text', text: '**Timeout in get_session**' }] };
|
||||
}),
|
||||
);
|
||||
|
||||
const lines = loggedLines(spy);
|
||||
expect(lines).toHaveLength(2);
|
||||
const written = new Set<string>();
|
||||
for (const line of lines) {
|
||||
expect(line.startsWith(contract.prefixes.tool_call)).toBe(true);
|
||||
for (const key of Object.keys(JSON.parse(line) as object)) written.add(key);
|
||||
}
|
||||
for (const field of contract.fields.tool_call) expect(written).toContain(field);
|
||||
});
|
||||
|
||||
it('writes the boot line with its type first and every field the overview reads', async () => {
|
||||
const { bootLine } = await loadTelemetry();
|
||||
const line = bootLine({ readyMs: 1, version: 'v', commit: 'c', docsCommit: 'd', rateLimitPerMinute: 60 });
|
||||
|
||||
expect(line.startsWith(contract.prefixes.boot)).toBe(true);
|
||||
const keys = Object.keys(JSON.parse(line) as object);
|
||||
for (const field of contract.fields.boot) expect(keys).toContain(field);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,8 @@ const ALLOWED_INLINE_ENUMS: Record<string, string> = {
|
||||
'search_knowledge corpus selector — MCP-side, spans two backend catalogs plus the error-code catalog.',
|
||||
"'inventory', 'unmatched'":
|
||||
'get_software_inventory scope selector — an MCP-side view switch.',
|
||||
"...OVERVIEW_VIEWS, 'organization', 'account'":
|
||||
'get_api_usage view selector — an MCP-side view switch over three backend routes (api-usage-shape.ts).',
|
||||
"'cmtrace', 'text'":
|
||||
'test_log_pattern input format — an MCP-side parser switch.',
|
||||
"'analyze', 'gather'":
|
||||
|
||||
@@ -19,8 +19,8 @@ const BASE_URL = API_BASE_URL;
|
||||
// Per-UPN request budget (sliding 60s window), applied AFTER a token is
|
||||
// validated. parsePositiveInt guards against a non-numeric override: a bare
|
||||
// parseInt would yield NaN, and `count >= NaN` is always false — silently
|
||||
// disabling the limiter.
|
||||
const RATE_LIMIT = parsePositiveInt(process.env.MCP_RATE_LIMIT_PER_MINUTE, 60);
|
||||
// disabling the limiter. The boot line reports it, so usage views read the live value.
|
||||
export const RATE_LIMIT_PER_MINUTE = parsePositiveInt(process.env.MCP_RATE_LIMIT_PER_MINUTE, 60);
|
||||
|
||||
// Per-source-IP budget for UNvalidated requests (sliding 60s window), applied
|
||||
// only on the cache-MISS path that would call the backend. Bounds forged- /
|
||||
@@ -357,7 +357,7 @@ function getClientIp(req: Request): string {
|
||||
}
|
||||
|
||||
function isRateLimited(upn: string): boolean {
|
||||
return isWindowExceeded(rateBuckets, upn, RATE_LIMIT);
|
||||
return isWindowExceeded(rateBuckets, upn, RATE_LIMIT_PER_MINUTE);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* LLM-facing shaping of the MCP usage views of get_api_usage:
|
||||
* - the all-tenant overview (GET /api/global/metrics/mcp-usage/overview, D-416) — one list per view, or the summary;
|
||||
* - one tenant's organization budget (GET /api/global/metrics/mcp-usage/organization?tenantId=);
|
||||
* - one account's quota counters (GET /api/metrics/mcp-usage/user/{userId}).
|
||||
*
|
||||
* The backend computes every figure (totals, minute shares, rankings) once for the admin page and this tool. This
|
||||
* layer only picks what a view shows, states the full length of every list the summary cuts, names tenants, folds an
|
||||
* account's counter rows into sums per tool and per day, and turns absent values into explicit nulls (the wire omits
|
||||
* them, which reads as "not applicable"). A quota window is { used, limit }: limit 0 = unlimited, used null = the
|
||||
* counter could not be read.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GetGlobalMcpUsageOverviewResponse,
|
||||
GetMcpOrganizationUsageResponse,
|
||||
GetMcpUserUsageResponse,
|
||||
McpUsageAccountItem,
|
||||
McpUsageMinuteItem,
|
||||
McpUsageQuotaHitItem,
|
||||
McpUsageTenantItem,
|
||||
McpUsageToolItem,
|
||||
} from './generated/wire-types.generated.js';
|
||||
|
||||
export const OVERVIEW_VIEWS = ['summary', 'tools', 'tenants', 'quotaHits', 'minuteLimits', 'daily'] as const;
|
||||
export type OverviewView = (typeof OVERVIEW_VIEWS)[number];
|
||||
|
||||
/** How many rows of each list the summary shows; the full list is one view away. */
|
||||
export const SUMMARY_HEAD = { tools: 10, tenants: 10, quotaHits: 10, minuteLimits: 5 } as const;
|
||||
|
||||
/** The tool of an account's counter key that carries none (a request sent without a tool name). */
|
||||
export const NO_TOOL = '(no tool)';
|
||||
|
||||
interface QuotaWindow {
|
||||
used: number | null;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** The wire marks an unreadable counter with -1. */
|
||||
function quotaWindow(used: number, limit: number): QuotaWindow {
|
||||
return { used: used < 0 ? null : used, limit };
|
||||
}
|
||||
|
||||
const dayKey = (d: Date) => d.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
|
||||
/** The trailing window of `days` whole UTC days ending today, as yyyyMMdd — the overview's own window. */
|
||||
export function usageWindow(days: number, now: Date = new Date()): { from: string; to: string } {
|
||||
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (days - 1)));
|
||||
return { from: dayKey(from), to: dayKey(now) };
|
||||
}
|
||||
|
||||
// ── Overview ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function overviewHead(data: GetGlobalMcpUsageOverviewResponse) {
|
||||
return {
|
||||
window: { from: data.dateFrom, to: data.dateTo },
|
||||
includesPlatformAdmins: data.includesPlatformAdmins,
|
||||
telemetry: {
|
||||
requestsAvailable: data.telemetry.requestsAvailable,
|
||||
mcpLogAvailable: data.telemetry.mcpLogAvailable,
|
||||
mcpLogFrom: data.telemetry.mcpLogFrom ?? null,
|
||||
partial: data.telemetry.partial ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolRow(t: McpUsageToolItem) {
|
||||
return {
|
||||
tool: t.tool,
|
||||
calls: t.calls,
|
||||
requestsPerCall: t.requestsPerCall,
|
||||
// null: the tool runs in the MCP server alone and never reaches the backend.
|
||||
accounts: t.accounts ?? null,
|
||||
tenants: t.tenants ?? null,
|
||||
errorRate: t.errorRate ?? null,
|
||||
p95DurationMs: t.p95DurationMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function tenantRow(t: McpUsageTenantItem) {
|
||||
return {
|
||||
tenantId: t.tenantId,
|
||||
domainName: t.domainName ?? null,
|
||||
plan: t.quota.tenantPlan,
|
||||
today: quotaWindow(t.quota.dailyUsed, t.quota.dailyLimit),
|
||||
month: quotaWindow(t.quota.monthlyUsed, t.quota.monthlyLimit),
|
||||
calls: t.calls,
|
||||
requests: t.requests,
|
||||
quotaHits: t.quotaHits,
|
||||
lastRequestAt: t.lastRequestAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function accountRow(a: McpUsageAccountItem) {
|
||||
return {
|
||||
principal: a.principal,
|
||||
userId: a.userId ?? null,
|
||||
role: a.role ?? null,
|
||||
isPlatformAdmin: a.isPlatformAdmin,
|
||||
plan: a.quota?.plan ?? null,
|
||||
today: a.quota ? quotaWindow(a.quota.dailyUsed, a.quota.dailyLimit) : null,
|
||||
month: a.quota ? quotaWindow(a.quota.monthlyUsed, a.quota.monthlyLimit) : null,
|
||||
calls: a.calls,
|
||||
requests: a.requests,
|
||||
peakDay: a.peakDay ?? null,
|
||||
peakDayRequests: a.peakDayRequests ?? null,
|
||||
quotaHits: a.quotaHits,
|
||||
topTools: a.topTools,
|
||||
lastRequestAt: a.lastRequestAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function quotaHitRow(h: McpUsageQuotaHitItem, domainName: string | null) {
|
||||
return {
|
||||
date: h.date,
|
||||
tenantId: h.tenantId,
|
||||
domainName,
|
||||
principal: h.principal ?? null,
|
||||
level: h.level,
|
||||
scope: h.scope,
|
||||
plan: h.plan,
|
||||
used: h.used ?? null,
|
||||
limit: h.limit ?? null,
|
||||
refused: h.refused,
|
||||
firstAt: h.firstAt,
|
||||
lastAt: h.lastAt,
|
||||
};
|
||||
}
|
||||
|
||||
function minuteRow(m: McpUsageMinuteItem, domainName: string | null) {
|
||||
return {
|
||||
tenantId: m.tenantId,
|
||||
domainName,
|
||||
principal: m.principal,
|
||||
sharePercent: m.sharePercent,
|
||||
peakCalls: m.peakCalls,
|
||||
peakCallsAt: m.peakCallsAt ?? null,
|
||||
mcpRefused: m.mcpRefused,
|
||||
peakRequests: m.peakRequests,
|
||||
peakRequestsAt: m.peakRequestsAt ?? null,
|
||||
backendLimit: m.backendLimit,
|
||||
backendRefused: m.backendRefused,
|
||||
};
|
||||
}
|
||||
|
||||
/** One view of the overview: the summary (every list cut to its head, full lengths stated) or one list in full. */
|
||||
export function shapeOverview(data: GetGlobalMcpUsageOverviewResponse, view: OverviewView): Record<string, unknown> {
|
||||
const names = new Map(data.tenants.filter((t) => t.domainName).map((t) => [t.tenantId.toLowerCase(), t.domainName!]));
|
||||
const nameOf = (tenantId: string) => names.get(tenantId.toLowerCase()) ?? null;
|
||||
const head = { view, ...overviewHead(data) };
|
||||
const minute = data.minuteLimits;
|
||||
|
||||
switch (view) {
|
||||
case 'summary': {
|
||||
const lists = {
|
||||
tools: data.tools.length,
|
||||
tenants: data.tenants.length,
|
||||
quotaHits: data.quotaHits.length,
|
||||
minuteLimits: minute.accounts.length,
|
||||
};
|
||||
const cut = (Object.keys(lists) as Array<keyof typeof lists>).some((k) => lists[k] > SUMMARY_HEAD[k]);
|
||||
return {
|
||||
...head,
|
||||
totals: { ...data.totals, calls: data.totals.calls ?? null, mcpOnlyCalls: data.totals.mcpOnlyCalls ?? null },
|
||||
tools: data.tools.slice(0, SUMMARY_HEAD.tools).map(toolRow),
|
||||
tenants: data.tenants.slice(0, SUMMARY_HEAD.tenants).map((t) => ({ ...tenantRow(t), accounts: t.accounts.length })),
|
||||
quotaHits: data.quotaHits.slice(0, SUMMARY_HEAD.quotaHits).map((h) => quotaHitRow(h, nameOf(h.tenantId))),
|
||||
minuteLimits: {
|
||||
mcpServerLimit: minute.mcpServerLimit ?? null,
|
||||
accounts: minute.accounts.slice(0, SUMMARY_HEAD.minuteLimits).map((m) => minuteRow(m, nameOf(m.tenantId))),
|
||||
},
|
||||
lists,
|
||||
...(cut ? { note: 'Lists are cut to their first rows; lists gives each full length, the view of the same name shows it in full.' } : {}),
|
||||
};
|
||||
}
|
||||
case 'tools':
|
||||
return { ...head, tools: data.tools.map((t) => ({ ...toolRow(t), avgResultChars: t.avgResultChars ?? null, overCapCalls: t.overCapCalls ?? null, routes: t.routes })) };
|
||||
case 'tenants':
|
||||
return { ...head, tenants: data.tenants.map((t) => ({ ...tenantRow(t), accounts: t.accounts.map(accountRow) })) };
|
||||
case 'quotaHits':
|
||||
return { ...head, quotaHits: data.quotaHits.map((h) => quotaHitRow(h, nameOf(h.tenantId))) };
|
||||
case 'minuteLimits':
|
||||
return {
|
||||
...head,
|
||||
mcpServerLimit: minute.mcpServerLimit ?? null,
|
||||
accounts: minute.accounts.map((m) => ({
|
||||
...minuteRow(m, nameOf(m.tenantId)),
|
||||
mcpLastRefusedAt: m.mcpLastRefusedAt ?? null,
|
||||
backendLastRefusedAt: m.backendLastRefusedAt ?? null,
|
||||
})),
|
||||
};
|
||||
case 'daily':
|
||||
return { ...head, daily: data.daily };
|
||||
}
|
||||
}
|
||||
|
||||
// ── One tenant ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One tenant's organization budget: its windows, every account charged to it, and the days. */
|
||||
export function shapeOrganization(data: GetMcpOrganizationUsageResponse): Record<string, unknown> {
|
||||
return {
|
||||
view: 'organization',
|
||||
tenantId: data.tenantId,
|
||||
window: { from: data.dateFrom, to: data.dateTo },
|
||||
plan: data.quota.tenantPlan,
|
||||
today: quotaWindow(data.quota.dailyUsed, data.quota.dailyLimit),
|
||||
month: quotaWindow(data.quota.monthlyUsed, data.quota.monthlyLimit),
|
||||
requests: data.users.reduce((sum, u) => sum + u.requestsInRange, 0),
|
||||
accounts: data.users.map((u) => ({
|
||||
principal: u.userPrincipalName ?? null,
|
||||
userId: u.userId,
|
||||
requestsInRange: u.requestsInRange,
|
||||
requestsToday: u.requestsToday,
|
||||
requestsThisMonth: u.requestsThisMonth,
|
||||
lastRequestAt: u.lastRequestAt ?? null,
|
||||
})),
|
||||
daily: data.daily,
|
||||
};
|
||||
}
|
||||
|
||||
// ── One account ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** A counter key is `tool:path`, or the bare path when the request carried no tool name. */
|
||||
function splitEndpoint(endpoint: string): { tool: string; path: string } {
|
||||
const colon = endpoint.indexOf(':');
|
||||
return colon > 0 ? { tool: endpoint.slice(0, colon), path: endpoint.slice(colon + 1) } : { tool: NO_TOOL, path: endpoint };
|
||||
}
|
||||
|
||||
const byRequestsThenName = <T extends { requests: number }>(name: (row: T) => string) =>
|
||||
(a: T, b: T) => b.requests - a.requests || name(a).localeCompare(name(b));
|
||||
|
||||
/** One account's charged requests in the window, by tool (with the paths it requested) and by day. */
|
||||
export function shapeAccount(data: GetMcpUserUsageResponse, window: { from: string; to: string }): Record<string, unknown> {
|
||||
const tools = new Map<string, Map<string, number>>();
|
||||
const days = new Map<string, number>();
|
||||
let newest: (typeof data.records)[number] | undefined;
|
||||
|
||||
for (const record of data.records) {
|
||||
const { tool, path } = splitEndpoint(record.endpoint);
|
||||
const paths = tools.get(tool) ?? new Map<string, number>();
|
||||
paths.set(path, (paths.get(path) ?? 0) + record.requestCount);
|
||||
tools.set(tool, paths);
|
||||
days.set(record.date, (days.get(record.date) ?? 0) + record.requestCount);
|
||||
if (!newest || record.lastRequestAt > newest.lastRequestAt) newest = record;
|
||||
}
|
||||
|
||||
const byTool = [...tools.entries()]
|
||||
.map(([tool, paths]) => ({
|
||||
tool,
|
||||
requests: [...paths.values()].reduce((sum, n) => sum + n, 0),
|
||||
paths: [...paths.entries()].map(([path, requests]) => ({ path, requests })).sort(byRequestsThenName((p) => p.path)),
|
||||
}))
|
||||
.sort(byRequestsThenName((t) => t.tool));
|
||||
|
||||
return {
|
||||
view: 'account',
|
||||
userId: data.userId,
|
||||
principal: newest?.userPrincipalName || null,
|
||||
tenantId: newest?.tenantId || null,
|
||||
window,
|
||||
requests: byTool.reduce((sum, t) => sum + t.requests, 0),
|
||||
byTool,
|
||||
byDay: [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, requests]) => ({ date, requests })),
|
||||
};
|
||||
}
|
||||
@@ -326,7 +326,7 @@ function tenantIdFromNextLink(continuation?: string): string | undefined {
|
||||
* nextLink's embedded tenantId when present (it is what actually gets sent), else the explicit arg, and
|
||||
* validate THAT against the managed set — which also blocks a hand-crafted continuation pointing at an
|
||||
* unmanaged tenant (defense in depth on top of the backend bound). No-op for non-delegated callers and
|
||||
* for offset-only continuations (geo-offset:/inv-offset:/usage-offset:), which re-send the explicit
|
||||
* for offset-only continuations (geo-offset:/inv-offset:), which re-send the explicit
|
||||
* tenantId every page anyway.
|
||||
*/
|
||||
export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user