Compare commits

...

18 Commits

Author SHA1 Message Date
Oliver Kieselbach 2e736f40ad feat(backend,shared): Intune Enrollment Validation admits enrolled devices without pre-registration (D-282)
The certificate's Intune managedDevice id is looked up in the tenant's Intune. With the tenant
switch ValidateIntuneDeviceBinding on, that lookup is the last accepting link of the validator
chain: an enrolled device that is not being retired, wiped or deleted is admitted as
ValidatorType.IntuneEnrollment. A 404 for a certificate younger than 60 minutes is
NotFoundRecentEnrollment (503, never cached); a missing grant is 503 with 120 s.

For devices another validator admitted, the lookup only observes: in the background, never
admitting or rejecting, and only when the app token carries
DeviceManagementManagedDevices.Read.All, so a tenant without the add-on costs no Graph call.
Outcomes ride on the request row (CertDeviceBinding, role, serial match, owner and enrollment
type).

TenantConfiguration.HasAnyDeviceValidation() is the single gate precondition; the feature flags
expose it as deviceValidationEnabled (replaces validateAutopilotDevice).

The agent parses ValidatorType strictly: release the agent before this backend.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:57:26 +02:00
Oliver Kieselbach 9c3e5ecb30 Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-25 06:55:36 +02:00
Oliver Kieselbach 27ce316206 bump pointer 2026-09-25 06:55:25 +02:00
Oliver Kieselbach 7280b61d28 fix(web): self-hosted AI clients are ones the organization hosts itself (#163)
"Runs on its own servers" read as on-premises only and collided with the
MCP server. The criterion is who hosts the client (on premises or in the
cloud), matching the customer docs.


Claude-Session: https://claude.ai/code/session_012P3YpD3CSsR8Sy1Ubg4pUw

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 05:43:05 +02:00
Oliver Kieselbach 79f1ecd378 Tenant Config Report: show self-hosted AI client registrations (#162)
The registrations (name, callback URL, client id) live in their own table,
not in TenantConfiguration, so the report only showed the limit. Fetch
GET tenants/{tenantId}/mcp-client-registrations fail-soft alongside the
other side views and render them in the Tenant Status section, plus the
platform switch. The limit row is relabelled to say it is a limit.


Claude-Session: https://claude.ai/code/session_01DbRs7NCppizQ7wWFjN7n3M

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 20:54:10 +02:00
Oliver Kieselbach b6791d37e0 ci(codeql): exclude js/missing-rate-limiting; a test pins every OAuth route to its limiter (D-281)
The query recognizes only npm limiter packages, never oauthRateLimit /
oauthTokenRateLimit, and flags a handler as soon as it calls a function
named like *auth*, *login* or *verify*. All six alerts it ever raised were
on throttled OAuth routes (the last two came from tenantAuthority()).

What it stood in for is now a test: every route of the OAuth router must
run a per-IP limiter first unless it is one of the three named discovery
documents, /oauth/token must carry the strict budget, and a router.use()
middleware fails the check. Verified by mutation: removing the limiter
from /oauth/authorize fails the test with the route name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 18:58:08 +02:00
Oliver Kieselbach 4f1b4f8c03 fix(mcp): URL-encode the registration id in the tenant-client lookup path
The registration lookup put a client_id-derived value into the backend URL
path unencoded. The amc_ regex already limits it to 32 hex characters, so
nothing changes at runtime, but every other request-derived path segment in
the MCP server goes through encodeURIComponent, and CodeQL counts only that
(or a ?/# prefix) as a request-forgery barrier - a regex capture group is
not one. Closes code-scanning alert js/request-forgery on oauth.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 18:33:32 +02:00
Oliver Kieselbach 869580c9df feat(web): progress portal deep link /progress?serial=
A ?serial= parameter (serial number or exact device name) runs one lookup
as soon as sign-in has resolved the tenant, so organisations can send users
a link to follow their own device. Every hit writes the found session's
serial back into the URL for reload, bookmark and copying. Authorization is
unchanged: sign-in stays mandatory and roleless callers still need the exact
serial server-side. The query survives the login redirect because
ProtectedRoute keeps pathname + search. progress_serial_submitted carries
source form | link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 17:14:39 +02:00
Oliver Kieselbach 1a96cd20da feat(web,backend): AI Integration settings page; one self-hosted client per tenant, raisable by a Global Admin
- Settings > Tenant > AI Integration (between SLA Targets and Bootstrap
  Sessions) explains the normal connection first - server URL for Claude,
  ChatGPT, VS Code and other hosted assistants, nothing to register - and
  shows the self-hosted client registration below it (Tenant Admin, platform
  switch on). The page is visible to admins, platform scopes and anyone with
  MCP access. The self-hosted example text is neutral.
- A tenant may hold one self-hosted client registration. The GA-only
  TenantConfiguration.McpClientRegistrationLimit (1-10) raises it on request:
  reverted for non-GA callers on PUT, refused on PATCH, edited in the Global
  Admin tenant dialog. MCP_SERVER_URL joins the portal URL registry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:00:15 +02:00
Oliver Kieselbach 8e637cf86a feat(web): docs link of the self-hosted AI clients section points at its own docs section
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:47:47 +02:00
Oliver Kieselbach 93c6e1facb feat(mcp): tenant-bound registrations for self-hosted MCP clients (D-280)
A self-hosted AI client redirects to its own domain, which the vendor
redirect allowlist never admits, so it could not use the browser sign-in.
Tenant Admins can now register the client's exact OAuth callback in the
portal and configure the client with client id amc_<registrationId>.

- MCP OAuth proxy: an amc_ id is resolved through the backend; its
  registration is the redirect allowlist entry for that exact callback.
  Authorize and token run at the registering tenant's Entra authority, and
  the token endpoint discards a token of any other tenant (code and
  refresh). Lookups are cached for 60 s, a failed lookup fails closed. Every
  other client keeps the allowlist check before its resolution.
- Backend: table McpClientRegistrations, TenantAdminOrGA routes
  tenants/{tenantId}/mcp-client-registrations (list, create, delete; at
  most three, https or loopback http, exact URI, audited) and the anonymous,
  per-IP rate-limited lookup auth/mcp/client-registrations/{id} (under
  /api/auth for the client-certificate exclusion list).
- AdminConfiguration.McpClientRegistrationEnabled (default off) is the
  platform switch: off hides the portal section and makes every lookup 404.
  auth/me carries it for the sidebar.
- Portal: Settings > Tenant > Self-hosted AI clients (Tenant Admin / Global
  Admin), and the switch in Admin > Settings > Global.
- Tests: MCP tenant-client flow (authority, tid check, revocation, cache,
  allowlist), backend round trip, wire parity, validation, cap, switch,
  audit; nav visibility. Manifests and wire types regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:10:13 +02:00
Oliver Kieselbach 4a4bbc056a fix(mcp): the OAuth proxy advertises and returns token auth method none
The proxy issues no client secrets, but its authorization server metadata
named no token_endpoint_auth_methods_supported (RFC 8414 then implies
client_secret_basic) and /oauth/register echoed a requested secret-based
method. A client following that default registered with client_secret_basic
and then failed the token exchange ("client_secret_basic authentication
requires a client_secret"), reproduced with the SDK calls a self-hosted chat
client makes. The metadata now lists ["none"] and registration always answers
none (RFC 7591 lets the server replace a requested value).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:35:47 +02:00
Oliver Kieselbach c59b366560 feat(auth): client-app binding for delegated tokens - measured always, enforced behind a switch (D-279)
A user token obtained by an application other than the platform's own
registrations (for example a self-hosted AI client using on-behalf-of) was
accepted with the user's full role; only audience, issuer, signature and
lifetime were checked. Such tokens are now classified on every request.

- ClientAppBindingMiddleware (after authentication, before policy enforcement)
  records the request dimensions ClientAppBinding (registered | unregistered |
  unidentified) and ClientAppId, plus a throttled Warning.
- AdminConfiguration.EnforceClientAppBinding (default off; Admin > Settings >
  Global): when on, a foreign client is refused with 403 ClientAppNotRegistered
  unless the application is an enabled member of the caller's tenant (the
  app:<client-id> row a service principal uses). An admitted caller keeps their
  identity but gets the application caps: at most Viewer, no platform role,
  DelegatedReader on managed tenants, roleless tiers closed except auth/mcp.
- AdminIdentity.IsCapped (application or admitted foreign client) is what the
  global, delegated and member-role resolvers switch on; RequestContext carries it.
- Own clients = the audience trust set plus EntraId:TrustedClientAppIds, a
  client-only trust for the local development registration that never widens
  the accepted audiences.
- Tests: classification, unforgeable cap marker, middleware in both modes, caps
  through the real policy middleware, pipeline order; manifests and wire types
  regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:28:21 +02:00
Oliver Kieselbach d0aded5401 chore: bump internal (D-278, MCP client plan draft)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:41:21 +02:00
Oliver Kieselbach 8f9c683b7c chore: bump infra (redirect-allowlist parameter description)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 09:45:42 +02:00
Oliver Kieselbach e82878af57 feat(mcp): name the rejected token's aud/azp/tid and the rejected redirect targets in the auth log lines
A backend 401 on `initialize` logged only the UPN, and the backend's own trace
("IDX10214 audience validation failed") never names the token; a registration
rejection logged only a count of redirect_uris. Diagnosing a self-hosted client
(LibreChat) that first failed DCR on its own callback host and then presented
tokens minted for another resource took a customer round trip.

- access-guard: `invalid-token-claims` and `backend-rejected-token` carry
  `aud=... azp=... tid=...` (unverified claims; control characters stripped, capped)
- oauth/register: the rejection names origin + path of up to three rejected
  redirect_uris (never the query); the error_description names the actual
  defaults (VS Code, not Google)
- tests for both lines; skill catalog in .claude/commands (mcp-telemetry) updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 09:44:20 +02:00
Oliver Kieselbach d60e858e95 chore: bump internal (signature-check lesson)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:27:04 +02:00
Oliver Kieselbach f6ce9ae0a0 chore: bump internal (web perf audit released, first RUM comparison)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 13:38:15 +02:00
88 changed files with 4166 additions and 269 deletions
+18 -6
View File
@@ -1,8 +1,8 @@
# CodeQL configuration.
#
# The default query suites stay active — this file only filters out two C# queries
# that fire structurally on this codebase, where the flagged behavior is the feature,
# not a defect:
# The default query suites stay active — this file only filters out queries that
# fire structurally on this codebase, where the flagged behavior is the feature
# or the protection exists in a form the query cannot see:
#
# cs/exposure-of-sensitive-information
# Audit and operational logs record WHO performed an action (actor UPN / email)
@@ -15,10 +15,20 @@
# key. The IP is an identifier for throttling, not a secret at rest; storing it in
# the in-memory / table rate-limit key is by design.
#
# js/missing-rate-limiting
# Only the MCP server has Express routes. Every one that does work is throttled by
# our own limiters (oauthRateLimit / oauthTokenRateLimit on /oauth/*, accessGuard
# on /mcp); /health and the OAuth discovery documents return static JSON.
# The query only recognizes limiters from npm packages (express-rate-limit and
# similar) and flags a route handler as soon as it calls a function named
# like *auth*, *login* or *verify*. So it reports throttled and unthrottled
# routes the same way, and every alert it ever raised was a false positive. The
# coverage check it stood in for is a test: every OAuth router route must carry
# a limiter or be named as exempt (MCP oauth-rate-limit.test.ts).
#
# Genuine one-off false positives from OTHER queries (e.g. an open-redirect the taint
# model can't see is allow-listed, a rate-limited route it doesn't recognize) are
# dismissed individually in the Security tab so those queries stay sharp — they are
# NOT suppressed here.
# model can't see is allow-listed) are dismissed individually in the Security tab so
# those queries stay sharp — they are NOT suppressed here.
#
# Log-injection (cs/log-forging) is deliberately NOT filtered: user-controlled values
# are sanitized at the sink via Helpers/LogSanitizer, so the query stays useful for
@@ -29,3 +39,5 @@ query-filters:
id: cs/exposure-of-sensitive-information
- exclude:
id: cs/cleartext-storage-of-sensitive-information
- exclude:
id: js/missing-rate-limiting
+1 -1
Submodule infra updated: 8a8f8c66dd...be0165a223
@@ -107,7 +107,8 @@ public class AuthFunctionSideEffectTests
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
new Mock<AdminIdentityResolver>(
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
_signalR);
_signalR,
adminConfigService.Object);
// Default: all fire-and-forget calls succeed
_tenantConfigMock
@@ -0,0 +1,498 @@
using System.Security.Claims;
using System.Text;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Client-app binding of delegated tokens: a user token obtained by an application outside the platform's
/// own registrations is measured always and, while <c>EnforceClientAppBinding</c> is on, admitted only when
/// that application is an enabled member of the caller's tenant — the person then acts under the same caps
/// as an application principal (Viewer, no platform role, DelegatedReader). The switch off must leave every
/// request exactly as it was: only the request-row items appear.
/// </summary>
public class ClientAppBindingTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string OwnClientId = "aaaaaaaa-0000-0000-0000-00000000000a";
private const string LegacyClientId = "bbbbbbbb-0000-0000-0000-00000000000b";
private const string ForeignClientId = "cccccccc-0000-0000-0000-00000000000c";
private const string UserUpn = "alice@contoso.example";
private const string UserOid = "dddddddd-0000-0000-0000-00000000000d";
private static readonly string ForeignAppKey = Constants.PrincipalKeys.ForApplication(ForeignClientId);
private static readonly string[] Trusted = { OwnClientId, LegacyClientId };
private static ClaimsPrincipal UserToken(string? clientId = ForeignClientId, string clientClaim = "azp",
string upn = UserUpn, string tenantId = TenantA)
{
var claims = new List<Claim>
{
new("tid", tenantId), new("upn", upn), new("oid", UserOid), new("scp", "access_as_user"),
};
if (clientId != null) claims.Add(new Claim(clientClaim, clientId));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "AuthenticationTypes.Federation"));
}
private static ClaimsPrincipal AppToken(string clientId = ForeignClientId)
=> new(new ClaimsIdentity(new[]
{
new Claim("tid", TenantA), new Claim("idtyp", "app"), new Claim("appid", clientId),
new Claim("oid", "eeeeeeee-0000-0000-0000-00000000000e"),
new Claim("roles", Constants.ApplicationPermissions.AccessAsApplication),
}, "AuthenticationTypes.Federation"));
// ── Classification ──────────────────────────────────────────────────────────
[Theory]
[InlineData(OwnClientId, "azp")]
[InlineData(LegacyClientId, "azp")]
[InlineData(OwnClientId, "appid")]
[InlineData("AAAAAAAA-0000-0000-0000-00000000000A", "azp")]
public void OwnRegistrations_AreNotForeign(string clientId, string claim)
=> Assert.False(ClientAppBinding.IsForeignDelegatedToken(UserToken(clientId, claim), Trusted, out _));
[Theory]
[InlineData("azp")]
[InlineData("appid")]
public void AnotherClient_IsForeign_AndNamed(string claim)
{
Assert.True(ClientAppBinding.IsForeignDelegatedToken(UserToken(ForeignClientId, claim), Trusted, out var id));
Assert.Equal(ForeignClientId, id);
}
[Fact]
public void ATokenNamingNoClient_IsForeign_Unidentified()
{
Assert.True(ClientAppBinding.IsForeignDelegatedToken(UserToken(clientId: null), Trusted, out var id));
Assert.Null(id);
}
[Fact]
public void AppOnlyTokens_AreNeverForeign_TheyHaveTheirOwnGate()
=> Assert.False(ClientAppBinding.IsForeignDelegatedToken(AppToken(), Trusted, out _));
[Fact]
public void TheTrustedSet_IsTheAudienceTrustSet_PrimaryLegacyAndAdditional()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = OwnClientId,
["EntraId:LegacyClientId"] = LegacyClientId,
["EntraId:AdditionalClientIds"] = ForeignClientId.ToUpperInvariant() + ", not-a-guid",
}).Build();
var ids = AuthenticationMiddleware.ResolveTrustedClientIds(config, out _, out var rejected);
Assert.Equal(new[] { OwnClientId, LegacyClientId, ForeignClientId }, ids);
Assert.Equal(new[] { "not-a-guid" }, rejected);
}
[Fact]
public void ClientOnlyTrust_WidensTheClientSet_ButNotTheAudiences()
{
const string DevClientId = "ffffffff-0000-0000-0000-00000000000f";
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = OwnClientId,
["EntraId:TrustedClientAppIds"] = DevClientId,
}).Build();
var clients = ClientAppBindingMiddleware.ResolveTrustedClientApps(config);
var audiences = AuthenticationMiddleware.ResolveTrustedClientIds(config, out _, out _);
Assert.Contains(DevClientId, clients);
Assert.Contains(OwnClientId, clients);
Assert.DoesNotContain(DevClientId, audiences);
Assert.False(ClientAppBinding.IsForeignDelegatedToken(UserToken(DevClientId), clients, out _));
}
// ── The cap marker ──────────────────────────────────────────────────────────
[Fact]
public void AMarkedPrincipal_KeepsItsIdentity_ButIsCapped()
{
var principal = UserToken();
ClientAppBinding.MarkCapped(principal, ForeignClientId);
var identity = AdminIdentity.FromPrincipal(principal)!;
Assert.True(principal.IsClientCapped());
Assert.True(principal.IsCappedPrincipal());
Assert.Equal(UserUpn, principal.GetUserPrincipalName());
Assert.Equal(UserUpn, identity.Upn);
Assert.False(identity.IsApplication);
Assert.True(identity.IsCapped);
}
[Fact]
public void ATokenCarryingTheMarkerClaim_IsNotCapped()
{
// The marker is an identity with the middleware's own authentication type — a token-derived identity
// can carry the same claim name but never that authentication type.
var principal = UserToken();
((ClaimsIdentity)principal.Identity!).AddClaim(new Claim(ClientAppBinding.ClientAppClaimType, ForeignClientId));
Assert.False(principal.IsClientCapped());
Assert.False(AdminIdentity.FromPrincipal(principal)!.IsCapped);
}
// ── Middleware: measure always, enforce behind the switch ─────────────────────
private sealed class Run
{
public required bool NextCalled { get; init; }
public required IDictionary<object, object> Items { get; init; }
public required HttpContext Http { get; init; }
public required ClaimsPrincipal? Principal { get; init; }
public string Body()
{
Http.Response.Body.Position = 0;
return new StreamReader(Http.Response.Body, Encoding.UTF8).ReadToEnd();
}
}
private static async Task<Run> InvokeAsync(ClaimsPrincipal? principal, bool enforced, TenantMember? appRow = null)
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
if (appRow != null)
repo.Setup(r => r.GetTenantMemberAsync(TenantA, ForeignAppKey)).ReturnsAsync(appRow);
var cache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { EnforceClientAppBinding = enforced });
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = OwnClientId,
["EntraId:LegacyClientId"] = LegacyClientId,
}).Build();
var sut = new ClientAppBindingMiddleware(
NullLogger<ClientAppBindingMiddleware>.Instance, config,
new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance), adminConfig.Object);
var http = new DefaultHttpContext();
http.Response.Body = new MemoryStream();
var items = new Dictionary<object, object> { ["HttpRequestContext"] = http, ["CorrelationId"] = "cid-1" };
if (principal != null) items["ClaimsPrincipal"] = principal;
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(items);
var nextCalled = false;
await sut.Invoke(context.Object, _ => { nextCalled = true; return Task.CompletedTask; });
return new Run { NextCalled = nextCalled, Items = items, Http = http, Principal = principal };
}
private static TenantMember AppRow(bool enabled = true)
=> new() { TenantId = TenantA, Upn = ForeignAppKey, Role = Constants.TenantRoles.Viewer, IsEnabled = enabled };
[Fact]
public async Task NoValidatedPrincipal_PassesThrough_Unmeasured()
{
var run = await InvokeAsync(principal: null, enforced: true);
Assert.True(run.NextCalled);
Assert.False(run.Items.ContainsKey(ClientAppBinding.OutcomeItemKey));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task OwnClientTokens_PassUntouched_InBothModes(bool enforced)
{
var run = await InvokeAsync(UserToken(OwnClientId), enforced);
Assert.True(run.NextCalled);
Assert.False(run.Items.ContainsKey(ClientAppBinding.OutcomeItemKey));
Assert.False(run.Principal!.IsClientCapped());
}
[Fact]
public async Task SwitchOff_MeasuresAnUnregisteredClient_AndLetsItThrough()
{
var run = await InvokeAsync(UserToken(), enforced: false);
Assert.True(run.NextCalled);
Assert.Equal(ClientAppBinding.Outcomes.Unregistered, run.Items[ClientAppBinding.OutcomeItemKey]);
Assert.Equal(ForeignClientId, run.Items[ClientAppBinding.ClientAppIdItemKey]);
Assert.False(run.Principal!.IsClientCapped());
}
[Fact]
public async Task SwitchOff_MeasuresARegisteredClient_WithoutCapping()
{
var run = await InvokeAsync(UserToken(), enforced: false, appRow: AppRow());
Assert.True(run.NextCalled);
Assert.Equal(ClientAppBinding.Outcomes.Registered, run.Items[ClientAppBinding.OutcomeItemKey]);
Assert.False(run.Principal!.IsClientCapped());
}
[Fact]
public async Task SwitchOn_RefusesAnUnregisteredClient_With403NamingTheApp()
{
var run = await InvokeAsync(UserToken(), enforced: true);
Assert.False(run.NextCalled);
Assert.Equal(403, run.Http.Response.StatusCode);
var body = run.Body();
Assert.Contains(Constants.ApiErrorCodes.ClientAppNotRegistered, body);
Assert.Contains(ForeignClientId, body);
Assert.Equal(ClientAppBinding.Outcomes.Unregistered, run.Items[ClientAppBinding.OutcomeItemKey]);
}
[Fact]
public async Task SwitchOn_RefusesADisabledAppRow()
{
var run = await InvokeAsync(UserToken(), enforced: true, appRow: AppRow(enabled: false));
Assert.False(run.NextCalled);
Assert.Equal(403, run.Http.Response.StatusCode);
}
[Fact]
public async Task SwitchOn_RefusesATokenNamingNoClient()
{
var run = await InvokeAsync(UserToken(clientId: null), enforced: true);
Assert.False(run.NextCalled);
Assert.Equal(403, run.Http.Response.StatusCode);
Assert.Equal(ClientAppBinding.Outcomes.Unidentified, run.Items[ClientAppBinding.OutcomeItemKey]);
}
[Fact]
public async Task SwitchOn_AdmitsARegisteredClient_AndCapsThePerson()
{
var run = await InvokeAsync(UserToken(), enforced: true, appRow: AppRow());
Assert.True(run.NextCalled);
Assert.True(run.Principal!.IsClientCapped());
Assert.Equal(UserUpn, run.Principal!.GetUserPrincipalName());
}
[Fact]
public async Task TheAppRowIsLookedUp_InTheCallersOwnTenant()
{
// A registration in another tenant admits nothing: the row must live in the token's tid.
var run = await InvokeAsync(UserToken(tenantId: TenantB), enforced: true, appRow: AppRow());
Assert.False(run.NextCalled);
Assert.Equal(403, run.Http.Response.StatusCode);
}
// ── Caps through the real policy middleware ─────────────────────────────────
private sealed class Harness
{
public required PolicyEnforcementMiddleware Middleware { get; init; }
public required Mock<IAdminRepository> Repo { get; init; }
public void UserRow(string role) =>
Repo.Setup(r => r.GetTenantMemberAsync(TenantA, UserUpn))
.ReturnsAsync(new TenantMember { TenantId = TenantA, Upn = UserUpn, Role = role, IsEnabled = true, CanManageBootstrapTokens = true });
}
private static Harness BuildHarness()
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(new List<DelegatedAdminEntry>());
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny<string>()))
.ReturnsAsync(new AdminIdentityBinding { TenantId = TenantA, ObjectId = UserOid });
var configRepo = new Mock<IConfigRepository>();
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync((TenantConfiguration?)null);
var cache = new MemoryCache(new MemoryCacheOptions());
var bindings = new AdminIdentityBindingService(repo.Object, cache, NullLogger<AdminIdentityBindingService>.Instance);
var globalAdmin = new GlobalAdminService(repo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance);
var delegatedAdmin = new DelegatedAdminService(
repo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro), cache, NullLogger<DelegatedAdminService>.Instance);
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
var tenantConfig = new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
var mw = new PolicyEnforcementMiddleware(
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin,
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig, new RecordingDenialReporter());
return new Harness { Middleware = mw, Repo = repo };
}
private static ClaimsPrincipal CappedUser()
{
var principal = UserToken();
ClientAppBinding.MarkCapped(principal, ForeignClientId);
return principal;
}
[Fact]
public async Task Control_TheSamePersonUncapped_KeepsTheirAdminRole()
{
var h = BuildHarness();
h.UserRow(Constants.TenantRoles.Admin);
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, UserToken(OwnClientId));
Assert.True(r.Allowed);
Assert.Equal(Constants.TenantRoles.Admin, r.Context!.UserRole);
Assert.False(r.Context.ClientCapped);
}
[Fact]
public async Task ACappedAdmin_ReadsAsViewer()
{
var h = BuildHarness();
h.UserRow(Constants.TenantRoles.Admin);
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, CappedUser());
Assert.True(r.Allowed);
Assert.Equal(Constants.TenantRoles.Viewer, r.Context!.UserRole);
Assert.False(r.Context.IsTenantAdmin);
Assert.True(r.Context.ClientCapped);
Assert.True(AdminIdentity.FromRequestContext(r.Context)!.IsCapped);
}
[Theory]
[InlineData("POST", "/api/sessions/abc/actions")]
[InlineData("PUT", "/api/sessions/abc/annotations/operator")]
[InlineData("POST", "/api/config/11111111-1111-1111-1111-111111111111")]
public async Task ACappedAdmin_CannotWrite(string method, string path)
{
var h = BuildHarness();
h.UserRow(Constants.TenantRoles.Admin);
var r = await h.Middleware.DecideAsync(method, path, null, CappedUser());
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
}
[Fact]
public async Task RolelessTiers_AreClosed_ButTheMcpFrontDoorIsOpen()
{
var h = BuildHarness();
h.UserRow(Constants.TenantRoles.Viewer);
var me = await h.Middleware.DecideAsync("GET", "/api/auth/me", null, CappedUser());
var mcp = await h.Middleware.DecideAsync("GET", "/api/auth/mcp", null, CappedUser());
Assert.False(me.Allowed);
Assert.Equal("ApplicationPrincipalNotAllowed", me.LogReason);
Assert.True(mcp.Allowed);
Assert.Equal(UserUpn, mcp.Context!.UserPrincipalName);
}
[Fact]
public async Task APlatformRole_IsInert_WhileCapped()
{
var h = BuildHarness();
h.Repo.Setup(r => r.GetGlobalRoleAsync(UserUpn)).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
var uncapped = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, UserToken(OwnClientId));
var capped = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, CappedUser());
Assert.True(uncapped.Allowed);
Assert.False(capped.Allowed);
Assert.Equal(403, capped.StatusCode);
}
[Fact]
public async Task ADelegatedAdminGrant_IsReaderOnly_WhileCapped()
{
var h = BuildHarness();
h.Repo.Setup(r => r.GetDelegatedTenantsAsync(UserUpn)).ReturnsAsync(new List<DelegatedAdminEntry>
{
new()
{
TenantId = TenantB, Role = Constants.DelegatedRoles.DelegatedAdmin, IsEnabled = true,
Status = Constants.DelegatedStatus.Active, Source = Constants.DelegatedSource.OperatorGranted,
},
});
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, CappedUser());
Assert.True(r.Allowed);
Assert.True(r.Context!.IsDelegatedReader);
Assert.False(r.Context.IsDelegatedAdmin);
}
// ── Resolver overload and the MCP front door ────────────────────────────────
[Theory]
[InlineData(false, Constants.TenantRoles.Admin, true)]
[InlineData(true, Constants.TenantRoles.Viewer, false)]
public async Task Resolver_CapsOnlyWhenAsked(bool capped, string expectedRole, bool expectedBootstrap)
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetTenantMemberAsync(TenantA, UserUpn))
.ReturnsAsync(new TenantMember { TenantId = TenantA, Upn = UserUpn, Role = Constants.TenantRoles.Admin, IsEnabled = true, CanManageBootstrapTokens = true });
var cache = new MemoryCache(new MemoryCacheOptions());
var sut = new TenantMemberRoleResolver(
new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance),
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
var role = await sut.ResolveAsync(TenantA, UserUpn, null, capped);
Assert.Equal(expectedRole, role?.Role);
Assert.Equal(expectedBootstrap, role!.CanManageBootstrapTokens);
}
[Fact]
public async Task Resolver_NeverTurnsANonMemberIntoAViewer()
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
var cache = new MemoryCache(new MemoryCacheOptions());
var sut = new TenantMemberRoleResolver(
new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance),
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
Assert.Null(await sut.ResolveAsync(TenantA, UserUpn, null, clientCapped: true));
}
[Fact]
public async Task McpAccessCheck_ResolvesTheCappedIdentity()
{
var adminRepo = new Mock<IAdminRepository>();
adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
var cache = new MemoryCache(new MemoryCacheOptions());
var bindings = new StubAdminIdentityBindingService(bound: true);
var seen = new List<AdminIdentity?>();
var globalAdmin = new Mock<GlobalAdminService>(adminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()))
.Callback<AdminIdentity?>(seen.Add).ReturnsAsync((string?)null);
var delegatedAdmin = new Mock<DelegatedAdminService>(
adminRepo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro), cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>()))
.Callback<AdminIdentity?>(seen.Add).ReturnsAsync(DelegatedScope.Empty);
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
adminConfig.Setup(x => x.GetConfigurationAsync())
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
var members = StubTenantMemberRoleResolver.Everyone();
var sut = new McpUserService(
adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, members,
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
var result = await sut.IsAllowedAsync(UserUpn, TenantA, UserOid, null, clientCapped: true);
Assert.True(result.IsAllowed);
Assert.Equal("AllMembers", result.AccessGrant);
Assert.NotEmpty(seen);
Assert.All(seen, identity => Assert.True(identity!.IsCapped));
}
}
@@ -44,6 +44,9 @@ public class GetTenantFeatureFlagsPayloadTests
// Non-sensitive by review: bool only ("is a contact address set"), never the
// address itself (admin-gated full config only).
"contactEmailSet",
// Non-sensitive by review: bool only ("is any device validation method enabled"),
// never which one or any Graph detail.
"deviceValidationEnabled",
"diagnosticsUploadConfigured",
"edition",
// Non-sensitive by review: one of "community" | "plan" | "trial" | "msp" — says WHY the
@@ -59,7 +62,6 @@ public class GetTenantFeatureFlagsPayloadTests
// wire options omit null keys (WhenWritingNull) — exactly what production always
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
"unrestrictedMode",
"validateAutopilotDevice",
}, fieldNames);
}
@@ -81,13 +83,30 @@ public class GetTenantFeatureFlagsPayloadTests
var element = Serialize(config);
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
Assert.True(element.GetProperty("validateAutopilotDevice").GetBoolean());
Assert.True(element.GetProperty("deviceValidationEnabled").GetBoolean());
Assert.False(element.GetProperty("showScriptOutput").GetBoolean());
Assert.True(element.GetProperty("enableSoftwareInventoryAnalyzer").GetBoolean());
Assert.False(element.GetProperty("enableIntegrityBypassAnalyzer").GetBoolean());
Assert.True(element.GetProperty("unrestrictedMode").GetBoolean());
}
[Theory]
[InlineData(false, false, false)]
[InlineData(true, false, true)]
[InlineData(false, true, true)]
public void Payload_DeviceValidationEnabled_IsAnyMethod_NotJustAutopilot(bool autopilot, bool intuneEnrollment, bool expected)
{
// A device-preparation tenant that validates only via Intune enrollment (or device
// association) must not see the "agent ingestion is blocked" banner.
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = autopilot,
ValidateIntuneDeviceBinding = intuneEnrollment,
});
Assert.Equal(expected, element.GetProperty("deviceValidationEnabled").GetBoolean());
}
// ── Effective feature gates (plan-derived) ──────────────────────────────
[Fact]
@@ -49,6 +49,7 @@ public class InfrastructureWireParityTests
homedApp = "primary",
bootstrapTokenEnabled = true,
unrestrictedModeEnabled = false,
mcpClientRegistrationEnabled = true,
whatsNewSeenPlatformUtc = platformSeen,
whatsNewSeenAgentUtc = agentSeen
},
@@ -69,6 +70,7 @@ public class InfrastructureWireParityTests
HomedApp = "primary",
BootstrapTokenEnabled = true,
UnrestrictedModeEnabled = false,
McpClientRegistrationEnabled = true,
WhatsNewSeenPlatformUtc = platformSeen,
WhatsNewSeenAgentUtc = agentSeen
});
@@ -105,6 +107,7 @@ public class InfrastructureWireParityTests
homedApp = "legacy",
bootstrapTokenEnabled = false,
unrestrictedModeEnabled = false,
mcpClientRegistrationEnabled = false,
whatsNewSeenPlatformUtc,
whatsNewSeenAgentUtc
},
@@ -125,6 +128,7 @@ public class InfrastructureWireParityTests
HomedApp = "legacy",
BootstrapTokenEnabled = false,
UnrestrictedModeEnabled = false,
McpClientRegistrationEnabled = false,
WhatsNewSeenPlatformUtc = null,
WhatsNewSeenAgentUtc = null
});
@@ -162,6 +162,7 @@ public class IntuneDeviceBindingValidatorTests
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
[InlineData(IntuneDeviceBindingOutcome.NoDeviceIdInCert)]
[InlineData(IntuneDeviceBindingOutcome.PermissionMissing)]
[InlineData(IntuneDeviceBindingOutcome.NotManaged)]
public void DefinitiveNegatives_AreNotTransient(IntuneDeviceBindingOutcome outcome)
{
// Only Transient may be retried / left uncached. A missing grant or a foreign device is a
@@ -0,0 +1,535 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Intune Enrollment Validation: the certificate's Intune device id admits a device without any
/// pre-registration (<see cref="IntuneDeviceBindingValidator.ValidateAsync"/>), and observes
/// devices another validator admitted (<see cref="IntuneDeviceBindingValidator.ObserveInBackground"/>)
/// — the latter only when the tenant granted the Graph permission, so a tenant without the add-on
/// costs no Graph call at all.
/// </summary>
public class IntuneEnrollmentValidationTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string DeviceId = "07bc0167-9061-43b7-b243-0bb6aeda736e";
private static readonly DateTimeOffset OldCert = DateTimeOffset.UtcNow.AddDays(-30);
private static readonly DateTimeOffset RecentCert = DateTimeOffset.UtcNow.AddMinutes(-5);
private const string MatchBody =
"{\"id\":\"07bc0167-9061-43b7-b243-0bb6aeda736e\",\"deviceName\":\"TEST-DEVICE-01\",\"managementState\":\"managed\","
+ "\"serialNumber\":\"TESTSERIAL01\",\"managedDeviceOwnerType\":\"company\",\"deviceEnrollmentType\":\"windowsAzureADJoin\"}";
// ------------------------------------------------------------------ harness
private sealed class ScriptedHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpResponseMessage>> _responses;
public List<HttpRequestMessage> Requests { get; } = new();
public ScriptedHandler(params Func<HttpResponseMessage>[] responses) => _responses = new(responses);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
lock (Requests) Requests.Add(request);
var next = _responses.Count > 1 ? _responses.Dequeue() : _responses.Peek();
return Task.FromResult(next());
}
}
private static Func<HttpResponseMessage> Respond(HttpStatusCode status, string body = "{}")
=> () => new HttpResponseMessage(status) { Content = new StringContent(body) };
private sealed record Sut(
IntuneDeviceBindingValidator Validator,
ScriptedHandler Handler,
MemoryCache Cache,
Mock<GraphTokenService> Tokens,
Mock<IGraphFeatureDetector> Detector);
private static Sut Build(bool permissionGranted, params Func<HttpResponseMessage>[] responses)
{
var handler = new ScriptedHandler(responses.Length == 0 ? new[] { Respond(HttpStatusCode.OK, MatchBody) } : responses);
var factory = new Mock<IHttpClientFactory>();
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(() => new HttpClient(handler, disposeHandler: false));
var cache = new MemoryCache(new MemoryCacheOptions());
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
["EntraId:ClientSecret"] = "secret",
}).Build();
var registry = new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance);
var tenantConfig = new Mock<TenantConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
var tokens = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
configuration, registry, tenantConfig.Object)
{ CallBase = false };
tokens.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
.ReturnsAsync(GraphTokenResult.Success("token"));
var detector = new Mock<IGraphFeatureDetector>();
detector.Setup(d => d.HasPermissionAsync(TenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, It.IsAny<CancellationToken>()))
.ReturnsAsync(permissionGranted);
var validator = new IntuneDeviceBindingValidator(
NullLogger<IntuneDeviceBindingValidator>.Instance, factory.Object, cache, tokens.Object, detector.Object);
return new Sut(validator, handler, cache, tokens, detector);
}
private static async Task WaitUntil(Func<bool> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!condition())
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException("Background observation did not settle in time.");
await Task.Delay(20);
}
}
private static bool InFlight(MemoryCache cache)
=> cache.TryGetValue(IntuneDeviceBindingValidator.BuildCacheKey(TenantId, DeviceId) + ":inflight", out _);
// ------------------------------------------------------------------ admitting lookups
[Fact]
public async Task Admitting_Match_IsCached_SoTheNextRequestCostsNoGraphCall()
{
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
var first = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
var second = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.Match, first.Outcome);
Assert.False(first.ServedFromCache);
Assert.True(second.ServedFromCache);
Assert.Single(sut.Handler.Requests);
Assert.Equal("TESTSERIAL01", second.SerialNumber);
}
[Fact]
public async Task Admitting_UsesAPointGetWithTheTelemetrySelect()
{
var sut = Build(permissionGranted: true);
await sut.Validator.ValidateAsync(TenantId, DeviceId.ToUpperInvariant(), OldCert, IntuneDeviceBindingRole.Admitting);
var uri = Uri.UnescapeDataString(Assert.Single(sut.Handler.Requests).RequestUri!.ToString());
Assert.Contains($"/v1.0/deviceManagement/managedDevices/{DeviceId}?", uri);
Assert.DoesNotContain("$filter", uri);
foreach (var field in new[] { "managementState", "serialNumber", "managedDeviceOwnerType", "deviceEnrollmentType", "azureADRegistered" })
Assert.Contains(field, uri);
}
[Fact]
public async Task Admitting_NotFound_WithAnOldCertificate_IsDefinitiveAndCached()
{
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.NotFound, result.Outcome);
Assert.Single(sut.Handler.Requests);
Assert.Equal(IntuneDeviceBindingOutcome.NotFound, sut.Validator.TryGetCached(TenantId, DeviceId)?.Outcome);
}
[Fact]
public async Task Admitting_NotFound_WithARecentCertificate_IsTransientAndNeverCached()
{
// The enrollment race: the device may not be visible in Graph yet. Caching the 404 would
// keep a genuine device out for the whole negative TTL.
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, RecentCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.NotFoundRecentEnrollment, result.Outcome);
Assert.True(result.IsTransient);
Assert.Equal(2, sut.Handler.Requests.Count);
Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
}
[Fact]
public async Task Admitting_RecentCertificate_ThenTheObjectAppears_IsAdmittedOnTheRetry()
{
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound), Respond(HttpStatusCode.OK, MatchBody));
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, RecentCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Equal(2, sut.Handler.Requests.Count);
}
[Fact]
public async Task Admitting_ForbiddenWithAFreshTokenToo_IsPermissionMissing()
{
var sut = Build(permissionGranted: false, Respond(HttpStatusCode.Forbidden), Respond(HttpStatusCode.Forbidden));
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.PermissionMissing, result.Outcome);
sut.Tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
}
[Fact]
public async Task Admitting_RunsEvenWhenThePermissionWasNotDetected()
{
// An admin who just ran the grant script must not wait for the detector's token cache:
// the admitting lookup goes to Graph and the 401/403 refresh picks up the new role.
var sut = Build(permissionGranted: false, Respond(HttpStatusCode.Forbidden), Respond(HttpStatusCode.OK, MatchBody));
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Admitting_InvalidDeviceId_NeverReachesGraph()
{
var sut = Build(permissionGranted: true);
var result = await sut.Validator.ValidateAsync(TenantId, "not-a-guid", OldCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.NoDeviceIdInCert, result.Outcome);
Assert.Empty(sut.Handler.Requests);
}
// ------------------------------------------------------------------ observation
[Fact]
public async Task Observe_WithoutThePermission_MakesNoGraphCall_AndAsksTheDetectorOncePerWindow()
{
var sut = Build(permissionGranted: false);
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
await WaitUntil(() => !InFlight(sut.Cache));
// Later requests of the same tenant stop at the cached "not granted" verdict.
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
sut.Validator.ObserveInBackground(TenantId, "22222222-3333-4444-5555-666666666666", OldCert, null);
await WaitUntil(() => !InFlight(sut.Cache));
Assert.Empty(sut.Handler.Requests);
sut.Tokens.Verify(t => t.GetAccessTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
sut.Detector.Verify(d => d.HasPermissionAsync(TenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, It.IsAny<CancellationToken>()), Times.Once);
Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
}
[Fact]
public async Task Observe_WithThePermission_FillsTheCacheInTheBackground()
{
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, "session-1");
await WaitUntil(() => sut.Validator.TryGetCached(TenantId, DeviceId) != null);
var observed = sut.Validator.TryGetCached(TenantId, DeviceId)!;
Assert.Equal(IntuneDeviceBindingOutcome.Match, observed.Outcome);
Assert.True(observed.ServedFromCache);
Assert.Single(sut.Handler.Requests);
}
[Fact]
public async Task Observe_WhenAlreadyCached_DoesNothing()
{
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
Assert.False(InFlight(sut.Cache));
Assert.Single(sut.Handler.Requests);
sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Observe_TransientOutcome_KeepsACooldownInsteadOfALookupPerRequest()
{
// A recent certificate whose device is not visible yet caches nothing; without the cooldown
// every request of that device would start a new Graph lookup.
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
sut.Validator.ObserveInBackground(TenantId, DeviceId, RecentCert, null);
await WaitUntil(() => sut.Handler.Requests.Count == 2);
await Task.Delay(100);
sut.Validator.ObserveInBackground(TenantId, DeviceId, RecentCert, null);
await Task.Delay(100);
Assert.True(InFlight(sut.Cache));
Assert.Equal(2, sut.Handler.Requests.Count);
Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
}
[Fact]
public void Observe_InvalidDeviceId_DoesNothing()
{
var sut = Build(permissionGranted: true);
sut.Validator.ObserveInBackground(TenantId, null, OldCert, null);
sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
// ------------------------------------------------------------------ SecurityValidator chain
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(TenantConfiguration config)
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
config.ManufacturerWhitelist = "*";
config.ModelWhitelist = "*";
var configService = new Mock<TenantConfigurationService>(
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
{ CallBase = false };
configService.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>())).ReturnsAsync((config, true));
var adminConfigService = new Mock<AdminConfigurationService>(
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
{ CallBase = false };
adminConfigService.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
var binding = new Mock<IntuneDeviceBindingValidator>(
NullLogger<IntuneDeviceBindingValidator>.Instance, Mock.Of<IHttpClientFactory>(), cache,
null!, Mock.Of<IGraphFeatureDetector>())
{ CallBase = false };
var validator = new SecurityValidator(
configService.Object, adminConfigService.Object,
new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
autopilotDeviceValidator: null!, corporateIdentifierValidator: null!,
NullLogger.Instance,
intuneDeviceBindingValidator: binding.Object);
return (validator, binding);
}
private static string CertTenant => CertTenantBindingTests.SampleCertTenantId.ToString();
private static void SetupAdmitting(Mock<IntuneDeviceBindingValidator> binding, IntuneDeviceBindingResult result)
=> binding.Setup(b => b.ValidateAsync(CertTenant, DeviceId, It.IsAny<DateTimeOffset?>(), IntuneDeviceBindingRole.Admitting,
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(result);
[Fact]
public async Task Chain_OnlyIntuneEnrollmentEnabled_PassesTheGate_AndAdmitsAnEnrolledDevice()
{
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
SetupAdmitting(binding, new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Match, IntuneDeviceId = DeviceId,
SerialNumber = "TESTSERIAL01", OwnerType = "personal", EnrollmentType = "windowsAzureADJoin",
});
var items = new Dictionary<object, object>();
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
Assert.True(result.IsValid, result.ErrorMessage + " " + result.Details);
Assert.Equal(ValidatorType.IntuneEnrollment, result.ValidatedBy);
Assert.Equal("IntuneEnrollment", items[RequestRowMarkers.DeviceValidationKey]);
Assert.Equal("Match", items[RequestRowMarkers.CertDeviceBindingKey]);
Assert.Equal("Admitting", items[RequestRowMarkers.CertDeviceBindingRoleKey]);
Assert.Equal("true", items[RequestRowMarkers.CertDeviceSerialMatchKey]);
Assert.Equal("personal", items[RequestRowMarkers.CertDeviceOwnerTypeKey]);
Assert.Equal("windowsAzureADJoin", items[RequestRowMarkers.CertDeviceEnrollmentTypeKey]);
// The admitting lookup already stamped the row; no second, observing lookup.
binding.Verify(b => b.ObserveInBackground(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<DateTimeOffset?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task Chain_PassesTheCertificateNotBeforeToTheLookup()
{
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
DateTimeOffset? seen = null;
binding.Setup(b => b.ValidateAsync(CertTenant, DeviceId, It.IsAny<DateTimeOffset?>(), IntuneDeviceBindingRole.Admitting,
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Callback<string, string?, DateTimeOffset?, IntuneDeviceBindingRole, string?, CancellationToken>((_, _, nb, _, _, _) => seen = nb)
.ReturnsAsync(new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.Match });
await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant);
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero), seen);
}
[Theory]
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
[InlineData(IntuneDeviceBindingOutcome.NotManaged)]
[InlineData(IntuneDeviceBindingOutcome.NoDeviceIdInCert)]
public async Task Chain_DefinitiveMiss_IsA403(IntuneDeviceBindingOutcome outcome)
{
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = outcome, ErrorMessage = "miss" });
var items = new Dictionary<object, object>();
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
Assert.False(result.IsValid);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
Assert.Equal(outcome.ToString(), items[RequestRowMarkers.CertDeviceBindingKey]);
Assert.Equal(RequestRowMarkers.DeviceValidation.Rejected, items[RequestRowMarkers.DeviceValidationKey]);
}
[Theory]
[InlineData(IntuneDeviceBindingOutcome.NotFoundRecentEnrollment, 30)]
[InlineData(IntuneDeviceBindingOutcome.Transient, 30)]
[InlineData(IntuneDeviceBindingOutcome.PermissionMissing, 120)]
public async Task Chain_NotYetDecidable_IsA503WithRetryAfter(IntuneDeviceBindingOutcome outcome, int retryAfter)
{
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = outcome, ErrorMessage = "later" });
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant);
Assert.False(result.IsValid);
Assert.Equal(HttpStatusCode.ServiceUnavailable, result.StatusCode);
Assert.Equal(retryAfter, result.RetryAfterSeconds);
}
[Fact]
public async Task Chain_ToggleOff_NoAdmittingLookup_OnlyABackgroundObservation()
{
var (validator, binding) = BuildChain(new TenantConfiguration { AllowInsecureAgentRequests = true });
var items = new Dictionary<object, object>();
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
Assert.True(result.IsValid);
binding.Verify(b => b.ValidateAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<DateTimeOffset?>(),
It.IsAny<IntuneDeviceBindingRole>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
binding.Verify(b => b.ObserveInBackground(CertTenant, DeviceId, It.IsAny<DateTimeOffset?>(), It.IsAny<string?>()), Times.Once);
Assert.False(items.ContainsKey(RequestRowMarkers.CertDeviceBindingKey));
}
[Fact]
public async Task Chain_CachedObservation_IsStampedAsObserving_AndNeverRejects()
{
var (validator, binding) = BuildChain(new TenantConfiguration { AllowInsecureAgentRequests = true });
binding.Setup(b => b.TryGetCached(CertTenant, DeviceId)).Returns(new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound, SerialNumber = "OTHER-SERIAL",
});
var items = new Dictionary<object, object>();
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
Assert.True(result.IsValid);
Assert.Equal("NotFound", items[RequestRowMarkers.CertDeviceBindingKey]);
Assert.Equal("Observing", items[RequestRowMarkers.CertDeviceBindingRoleKey]);
Assert.Equal("false", items[RequestRowMarkers.CertDeviceSerialMatchKey]);
binding.Verify(b => b.ObserveInBackground(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<DateTimeOffset?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task Chain_NothingEnabled_IsStillRejectedAtTheGate()
{
var (validator, _) = BuildChain(new TenantConfiguration());
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant);
Assert.False(result.IsValid);
Assert.Equal("Device validation is required", result.ErrorMessage);
}
// ------------------------------------------------------------------ gate + wire
[Theory]
[InlineData(nameof(TenantConfiguration.ValidateAutopilotDevice))]
[InlineData(nameof(TenantConfiguration.ValidateCorporateIdentifier))]
[InlineData(nameof(TenantConfiguration.ValidateDeviceAssociation))]
[InlineData(nameof(TenantConfiguration.ValidateCloudPcDevice))]
[InlineData(nameof(TenantConfiguration.ValidateIntuneDeviceBinding))]
public void HasAnyDeviceValidation_EachMethodCountsAlone(string property)
{
var config = new TenantConfiguration();
Assert.False(config.HasAnyDeviceValidation());
typeof(TenantConfiguration).GetProperty(property)!.SetValue(config, true);
Assert.True(config.HasAnyDeviceValidation());
}
[Fact]
public void HasAnyDeviceValidation_CoversEveryValidateFlag()
{
// A new Validate* flag that the gate does not know would leave its tenants locked out.
var flags = typeof(TenantConfiguration).GetProperties()
.Where(p => p.PropertyType == typeof(bool) && p.Name.StartsWith("Validate", StringComparison.Ordinal))
.Select(p => p.Name)
.ToList();
foreach (var flag in flags)
{
var config = new TenantConfiguration();
typeof(TenantConfiguration).GetProperty(flag)!.SetValue(config, true);
Assert.True(config.HasAnyDeviceValidation(), $"{flag} is not part of HasAnyDeviceValidation");
}
}
[Fact]
public void RegisterWire_EveryValidatorTheBackendSends_ParsesInTheAgent()
{
// The backend writes enum names (ApiJsonOptions); the agent reads the same Shared model
// with a plain JsonConvert.DeserializeObject. An agent built from this Shared therefore
// parses every member — which is why a new member ships with an agent release first.
foreach (var value in Enum.GetValues<ValidatorType>())
{
var json = JsonSerializer.Serialize(new RegisterSessionResponse { ValidatedBy = value }, ApiJsonOptions.Create());
Assert.Contains($"\"validatedBy\":\"{value}\"", json);
var parsed = Newtonsoft.Json.JsonConvert.DeserializeObject<RegisterSessionResponse>(json);
Assert.Equal(value, parsed!.ValidatedBy);
}
}
private enum ReleasedAgentValidatorType { Unknown = 0, AutopilotV1 = 1, CorporateIdentifier = 2, DeviceAssociation = 3, Bootstrap = 4, CloudPc = 5 }
private sealed class ReleasedAgentResponse
{
public ReleasedAgentValidatorType ValidatedBy { get; set; }
}
[Fact]
public void RegisterWire_AnAgentWithoutTheMember_FailsTheRegistration()
{
// Why the release order matters: an agent that predates IntuneEnrollment throws on it,
// and a failed parse counts as a failed registration. Agents self-update at every start,
// so releasing the agent before the backend closes the gap.
Assert.ThrowsAny<Newtonsoft.Json.JsonException>(() =>
Newtonsoft.Json.JsonConvert.DeserializeObject<ReleasedAgentResponse>("{\"validatedBy\":\"IntuneEnrollment\"}"));
}
[Theory]
[InlineData("TESTSERIAL01", "testserial01", true)]
[InlineData(" TESTSERIAL01 ", "TESTSERIAL01", true)]
[InlineData("TESTSERIAL01", "OTHER", false)]
[InlineData(null, "TESTSERIAL01", null)]
[InlineData("TESTSERIAL01", "", null)]
public void SerialMatch_ComparesTrimmedAndCaseInsensitive(string? header, string? intune, bool? expected)
=> Assert.Equal(expected, RequestRowMarkers.SerialMatch(header, intune));
}
@@ -0,0 +1,266 @@
using System.Net;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Self-hosted MCP client registrations: storage round trip, wire parity, callback validation (the MCP proxy
/// redirects authorization codes to the stored URL), the per-tenant cap, the operator switch (create refused,
/// lookup 404, delete still possible) and the audit trail.
/// </summary>
public class McpClientRegistrationTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string Callback = "https://chat.contoso.example/api/mcp/autopilot-monitor/oauth/callback";
private static readonly DateTime Now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc);
// ── Storage and wire ────────────────────────────────────────────────────────
[Fact]
public void Roundtrip_FullRow_SurvivesBuildAndMap()
{
var row = new McpClientRegistration
{
RegistrationId = "0123456789abcdef0123456789abcdef",
TenantId = TenantA.ToUpperInvariant(),
Name = "Team chat",
RedirectUri = Callback,
CreatedBy = "admin@contoso.example",
CreatedAt = Now,
};
var entity = TableMcpClientRegistrationRepository.Build(row);
var back = TableMcpClientRegistrationRepository.Map(entity);
Assert.Equal(TableMcpClientRegistrationRepository.PartitionKey, entity.PartitionKey);
Assert.Equal(row.RegistrationId, entity.RowKey);
Assert.Equal(row.RegistrationId, back.RegistrationId);
Assert.Equal(TenantA, back.TenantId); // lowercase: the offboarding wipe matches it
Assert.Equal(row.Name, back.Name);
Assert.Equal(row.RedirectUri, back.RedirectUri);
Assert.Equal(row.CreatedBy, back.CreatedBy);
Assert.Equal(Now, back.CreatedAt);
}
[Fact]
public void TenantFilter_IsPartitionPlusLowercaseTenant()
=> Assert.Equal($"PartitionKey eq 'registrations' and TenantId eq '{TenantA}'",
TableMcpClientRegistrationRepository.BuildTenantFilter(TenantA.ToUpperInvariant()));
[Fact]
public void ListResponse_WireShape()
{
var item = McpClientRegistrationFunction.ToItem(new McpClientRegistration
{
RegistrationId = "abc", TenantId = TenantA, Name = "Team chat", RedirectUri = Callback,
CreatedBy = "admin@contoso.example", CreatedAt = Now,
});
ApiResponseWireParityTests.AssertWireIdentical(
new
{
enabled = true,
maxRegistrations = 3,
serverUrl = "https://mcp.autopilotmonitor.com/mcp",
registrations = new[]
{
new { registrationId = "abc", clientId = "amc_abc", name = "Team chat", redirectUri = Callback, createdBy = "admin@contoso.example", createdUtc = Now },
},
},
new McpClientRegistrationListResponse
{
Enabled = true, MaxRegistrations = 3, ServerUrl = "https://mcp.autopilotmonitor.com/mcp",
Registrations = new[] { item },
});
}
[Fact]
public void CreateAndLookupResponses_WireShape()
{
var item = new McpClientRegistrationItem
{
RegistrationId = "abc", ClientId = "amc_abc", Name = "Team chat", RedirectUri = Callback,
CreatedBy = "admin@contoso.example", CreatedUtc = Now,
};
ApiResponseWireParityTests.AssertWireIdentical(
new { registration = new { registrationId = "abc", clientId = "amc_abc", name = "Team chat", redirectUri = Callback, createdBy = "admin@contoso.example", createdUtc = Now } },
new CreateMcpClientRegistrationResponse { Registration = item });
ApiResponseWireParityTests.AssertWireIdentical(
new { registrationId = "abc", tenantId = TenantA, redirectUri = Callback, name = "Team chat" },
new McpClientRegistrationLookupResponse { RegistrationId = "abc", TenantId = TenantA, RedirectUri = Callback, Name = "Team chat" });
}
// ── Validation ──────────────────────────────────────────────────────────────
[Theory]
[InlineData(Callback)]
[InlineData("http://localhost:3080/api/mcp/autopilot-monitor/oauth/callback")]
[InlineData("http://127.0.0.1:3080/cb")]
[InlineData("https://chat.contoso.example:8443/cb/")]
public void AcceptableCallbacks(string uri) => Assert.Null(McpClientRegistrationService.ValidateRedirectUri(uri));
[Theory]
[InlineData("")]
[InlineData("chat.contoso.example/cb")]
[InlineData("http://chat.contoso.example/cb")] // http off loopback
[InlineData("https://chat.contoso.example/cb?x=1")] // query
[InlineData("https://chat.contoso.example/cb#frag")] // fragment
[InlineData("https://*.contoso.example/cb")] // wildcard
[InlineData("https://user:pw@chat.contoso.example/cb")]
[InlineData("javascript:alert(1)")]
[InlineData("ftp://chat.contoso.example/cb")]
public void RejectedCallbacks(string uri) => Assert.NotNull(McpClientRegistrationService.ValidateRedirectUri(uri));
[Fact]
public void OverlongCallback_IsRejected()
=> Assert.NotNull(McpClientRegistrationService.ValidateRedirectUri("https://chat.contoso.example/" + new string('a', 1100)));
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("tab\there")]
public void RejectedNames(string name) => Assert.NotNull(McpClientRegistrationService.ValidateName(name));
[Fact]
public void OverlongName_IsRejected() => Assert.NotNull(McpClientRegistrationService.ValidateName(new string('n', 65)));
// ── Service: switch, cap, audit, tenant isolation ───────────────────────────
private sealed class Harness
{
public required McpClientRegistrationService Sut { get; init; }
public required Mock<IMcpClientRegistrationRepository> Repo { get; init; }
public required Mock<IMaintenanceRepository> Audit { get; init; }
}
private static Harness Build(bool enabled = true, int? tenantLimit = null)
{
var rows = new List<McpClientRegistration>();
var repo = new Mock<IMcpClientRegistrationRepository>();
repo.Setup(r => r.GetForTenantAsync(It.IsAny<string>()))
.ReturnsAsync((string t) => rows.Where(r => r.TenantId == t.ToLowerInvariant()).ToList());
repo.Setup(r => r.GetAsync(It.IsAny<string>())).ReturnsAsync((string id) => rows.FirstOrDefault(r => r.RegistrationId == id));
repo.Setup(r => r.CreateAsync(It.IsAny<McpClientRegistration>())).Callback<McpClientRegistration>(rows.Add).ReturnsAsync(true);
repo.Setup(r => r.DeleteAsync(It.IsAny<string>())).ReturnsAsync((string id) => rows.RemoveAll(r => r.RegistrationId == id) > 0);
var audit = new Mock<IMaintenanceRepository>();
audit.Setup(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
.ReturnsAsync(true);
var cache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { McpClientRegistrationEnabled = enabled });
var configRepo = new Mock<IConfigRepository>();
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync((string t) => new TenantConfiguration { TenantId = t, McpClientRegistrationLimit = t == TenantA ? tenantLimit : null });
var tenantConfig = new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
var sut = new McpClientRegistrationService(repo.Object, audit.Object, adminConfig.Object, tenantConfig, NullLogger<McpClientRegistrationService>.Instance);
return new Harness { Sut = sut, Repo = repo, Audit = audit };
}
[Fact]
public async Task Create_StoresARegistration_WithARandomHexId_AndAudits()
{
var h = Build();
var result = await h.Sut.CreateAsync(TenantA.ToUpperInvariant(), " Team chat ", Callback, "admin@contoso.example");
Assert.Equal(HttpStatusCode.Created, result.Status);
var reg = result.Registration!;
Assert.True(McpClientRegistrationService.IsRegistrationId(reg.RegistrationId));
Assert.Equal(TenantA, reg.TenantId);
Assert.Equal("Team chat", reg.Name);
Assert.Equal($"amc_{reg.RegistrationId}", McpClientRegistrationService.ClientIdOf(reg.RegistrationId));
h.Audit.Verify(a => a.LogAuditEntryAsync(TenantA, "CREATE", "McpClientRegistration", reg.RegistrationId, "admin@contoso.example",
It.Is<Dictionary<string, string>?>(d => d != null && d["RedirectUri"] == Callback)), Times.Once);
}
[Fact]
public async Task Create_IsRefused_WhileTheSwitchIsOff()
{
var h = Build(enabled: false);
var result = await h.Sut.CreateAsync(TenantA, "Team chat", Callback, "admin@contoso.example");
Assert.Equal(HttpStatusCode.Forbidden, result.Status);
h.Repo.Verify(r => r.CreateAsync(It.IsAny<McpClientRegistration>()), Times.Never);
}
[Fact]
public async Task Create_AllowsOneRegistrationByDefault_PerTenant()
{
var h = Build();
Assert.Equal(HttpStatusCode.Created, (await h.Sut.CreateAsync(TenantA, "chat", Callback, "a")).Status);
var second = await h.Sut.CreateAsync(TenantA, "chat 2", "https://chat2.contoso.example/cb", "a");
var otherTenant = await h.Sut.CreateAsync(TenantB, "chat", Callback, "b");
Assert.Equal(HttpStatusCode.Conflict, second.Status);
Assert.Contains("raise the limit", second.Error);
Assert.Equal(HttpStatusCode.Created, otherTenant.Status); // the limit and the duplicate check are per tenant
Assert.Equal(1, await h.Sut.GetLimitAsync(TenantA));
}
[Fact]
public async Task AGlobalAdminOverride_RaisesTheLimit_AndDuplicatesStayRefused()
{
var h = Build(tenantLimit: 3);
Assert.Equal(3, await h.Sut.GetLimitAsync(TenantA.ToUpperInvariant()));
for (var i = 0; i < 3; i++)
Assert.Equal(HttpStatusCode.Created, (await h.Sut.CreateAsync(TenantA, $"chat {i}", $"https://chat{i}.contoso.example/cb", "a")).Status);
Assert.Equal(HttpStatusCode.Conflict, (await h.Sut.CreateAsync(TenantA, "chat 4", "https://chat4.contoso.example/cb", "a")).Status);
var dup = Build(tenantLimit: 3);
await dup.Sut.CreateAsync(TenantA, "one", Callback, "a");
Assert.Equal(HttpStatusCode.Conflict, (await dup.Sut.CreateAsync(TenantA, "two", Callback.ToUpperInvariant(), "a")).Status);
}
[Theory]
[InlineData(0, 1)]
[InlineData(50, 10)]
public async Task AnOutOfRangeOverride_IsClamped(int stored, int effective)
=> Assert.Equal(effective, await Build(tenantLimit: stored).Sut.GetLimitAsync(TenantA));
[Fact]
public async Task Create_ValidatesBeforeStoring()
{
var h = Build();
Assert.Equal(HttpStatusCode.BadRequest, (await h.Sut.CreateAsync(TenantA, "x", "http://chat.contoso.example/cb", "a")).Status);
Assert.Equal(HttpStatusCode.BadRequest, (await h.Sut.CreateAsync(TenantA, "", Callback, "a")).Status);
h.Repo.Verify(r => r.CreateAsync(It.IsAny<McpClientRegistration>()), Times.Never);
}
[Fact]
public async Task Delete_OnlyTheOwnTenantsRegistration_AlsoWhileTheSwitchIsOff()
{
var h = Build();
var reg = (await h.Sut.CreateAsync(TenantA, "Team chat", Callback, "a")).Registration!;
Assert.False(await h.Sut.DeleteAsync(TenantB, reg.RegistrationId, "b"));
Assert.False(await h.Sut.DeleteAsync(TenantA, "not-an-id", "a"));
Assert.True(await h.Sut.DeleteAsync(TenantA.ToUpperInvariant(), reg.RegistrationId, "a"));
h.Audit.Verify(a => a.LogAuditEntryAsync(TenantA, "DELETE", "McpClientRegistration", reg.RegistrationId, "a", It.IsAny<Dictionary<string, string>?>()), Times.Once);
}
[Fact]
public async Task Lookup_AnswersOnlyWhileEnabled_AndOnlyForWellFormedIds()
{
var on = Build();
var reg = (await on.Sut.CreateAsync(TenantA, "Team chat", Callback, "a")).Registration!;
Assert.NotNull(await on.Sut.LookupAsync(reg.RegistrationId));
Assert.Null(await on.Sut.LookupAsync(reg.RegistrationId.ToUpperInvariant()));
Assert.Null(await on.Sut.LookupAsync("../../etc"));
var off = Build(enabled: false);
off.Repo.Setup(r => r.GetAsync(reg.RegistrationId)).ReturnsAsync(reg);
Assert.Null(await off.Sut.LookupAsync(reg.RegistrationId));
off.Repo.Verify(r => r.GetAsync(It.IsAny<string>()), Times.Never);
}
}
@@ -40,6 +40,21 @@ public class MiddlewarePipelineOrderTests
Assert.True(rateLimit < 0 || policy < rateLimit, "UserRateLimitMiddleware keys off the RequestContext the policy step resolves");
}
[Fact]
public void Client_app_binding_runs_between_authentication_and_policy_enforcement()
{
// It reads the principal the auth step stores and sets the cap marker the policy step resolves roles with.
var src = ProgramSource();
var auth = src.IndexOf($"UseMiddleware<{nameof(AuthenticationMiddleware)}>()", System.StringComparison.Ordinal);
var binding = src.IndexOf($"UseMiddleware<{nameof(ClientAppBindingMiddleware)}>()", System.StringComparison.Ordinal);
var policy = src.IndexOf($"UseMiddleware<{nameof(PolicyEnforcementMiddleware)}>()", System.StringComparison.Ordinal);
Assert.True(binding >= 0, "ClientAppBindingMiddleware is not registered in Program.cs");
Assert.True(auth < binding && binding < policy, "ClientAppBindingMiddleware must run after authentication and before policy enforcement");
Assert.Equal(1, Count(src, $"UseMiddleware<{nameof(ClientAppBindingMiddleware)}>()"));
}
[Fact]
public void Each_gate_middleware_is_registered_exactly_once()
{
@@ -373,7 +373,7 @@ public class TypedContractCompletionWireParityTests
{
bootstrapTokenEnabled = true,
diagnosticsUploadConfigured = false,
validateAutopilotDevice = true,
deviceValidationEnabled = true,
appHomingFunnelActive = false,
showScriptOutput = true,
enableSoftwareInventoryAnalyzer = false,
@@ -399,7 +399,7 @@ public class TypedContractCompletionWireParityTests
{
BootstrapTokenEnabled = true,
DiagnosticsUploadConfigured = false,
ValidateAutopilotDevice = true,
DeviceValidationEnabled = true,
AppHomingFunnelActive = false,
ShowScriptOutput = true,
EnableSoftwareInventoryAnalyzer = false,
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.DataAccess
services.AddSingleton<IDeviceSecurityRepository, TableDeviceSecurityRepository>();
services.AddSingleton<IUserUsageRepository, TableUserUsageRepository>();
services.AddSingleton<IDelegationInvitationRepository, TableDelegationInvitationRepository>();
services.AddSingleton<IMcpClientRegistrationRepository, TableMcpClientRegistrationRepository>();
services.AddSingleton<IDistressReportRepository, TableDistressReportRepository>();
services.AddSingleton<IOpsEventRepository, TableOpsEventRepository>();
@@ -670,6 +670,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "DisabledUntil", config.DisabledUntil },
{ "McpDisabled", config.McpDisabled },
{ "McpDisabledReason", config.McpDisabledReason },
{ "McpClientRegistrationLimit", config.McpClientRegistrationLimit },
{ "CustomRateLimitRequestsPerMinute", config.CustomRateLimitRequestsPerMinute },
{ "CustomUserRateLimitRequestsPerMinute", config.CustomUserRateLimitRequestsPerMinute },
{ "ManufacturerWhitelist", config.ManufacturerWhitelist },
@@ -784,6 +785,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
DisabledUntil = entity.GetDateTime("DisabledUntil"),
McpDisabled = entity.GetBoolean("McpDisabled") ?? false,
McpDisabledReason = entity.GetString("McpDisabledReason"),
McpClientRegistrationLimit = entity.GetInt32("McpClientRegistrationLimit"),
CustomRateLimitRequestsPerMinute = entity.GetInt32("CustomRateLimitRequestsPerMinute"),
CustomUserRateLimitRequestsPerMinute = entity.GetInt32("CustomUserRateLimitRequestsPerMinute"),
ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation",
@@ -950,6 +952,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "FeedbackCooldownDays", config.FeedbackCooldownDays },
// MCP access control
{ "McpAccessPolicy", config.McpAccessPolicy ?? "WhitelistOnly" },
{ "EnforceClientAppBinding", config.EnforceClientAppBinding },
{ "McpClientRegistrationEnabled", config.McpClientRegistrationEnabled },
// Agent endpoint migration (config-channel re-home)
{ "AgentMigrateApiBaseUrl", config.AgentMigrateApiBaseUrl ?? string.Empty },
{ "AgentMigrateTenantOverridesJson", config.AgentMigrateTenantOverridesJson ?? string.Empty }
@@ -1019,6 +1023,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
FeedbackCooldownDays = entity.GetInt32("FeedbackCooldownDays") ?? 60,
// MCP access control
McpAccessPolicy = entity.GetString("McpAccessPolicy") ?? "WhitelistOnly",
EnforceClientAppBinding = entity.GetBoolean("EnforceClientAppBinding") ?? false,
McpClientRegistrationEnabled = entity.GetBoolean("McpClientRegistrationEnabled") ?? false,
// Agent endpoint migration (config-channel re-home)
AgentMigrateApiBaseUrl = entity.GetString("AgentMigrateApiBaseUrl") ?? string.Empty,
AgentMigrateTenantOverridesJson = entity.GetString("AgentMigrateTenantOverridesJson") ?? string.Empty
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
/// <summary>
/// Table-storage implementation of <see cref="IMcpClientRegistrationRepository"/>.
/// PK = <see cref="PartitionKey"/> for every tenant, RK = registration id; the tenant view is a
/// property filter, the MCP server's lookup a point read.
/// </summary>
public class TableMcpClientRegistrationRepository : IMcpClientRegistrationRepository
{
internal const string PartitionKey = "registrations";
private readonly TableClient _table;
private readonly ILogger<TableMcpClientRegistrationRepository> _logger;
public TableMcpClientRegistrationRepository(TableStorageService storage, ILogger<TableMcpClientRegistrationRepository> logger)
{
_table = storage.GetTableClient(Constants.TableNames.McpClientRegistrations);
_logger = logger;
}
public async Task<bool> CreateAsync(McpClientRegistration registration)
{
try
{
await _table.AddEntityAsync(Build(registration));
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store MCP client registration {RegistrationId}", registration.RegistrationId);
return false;
}
}
public async Task<McpClientRegistration?> GetAsync(string registrationId)
{
try
{
var response = await _table.GetEntityIfExistsAsync<TableEntity>(PartitionKey, registrationId);
return response.HasValue && response.Value != null ? Map(response.Value) : null;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
}
public async Task<List<McpClientRegistration>> GetForTenantAsync(string tenantId)
{
var results = new List<McpClientRegistration>();
try
{
await foreach (var entity in _table.QueryAsync<TableEntity>(filter: BuildTenantFilter(tenantId)))
results.Add(Map(entity));
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
_logger.LogDebug("McpClientRegistrations table does not exist yet, returning empty list");
}
return results;
}
public async Task<bool> DeleteAsync(string registrationId)
{
try
{
await _table.DeleteEntityAsync(PartitionKey, registrationId, ETag.All);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
}
internal static string BuildTenantFilter(string tenantId)
=> $"PartitionKey eq '{PartitionKey}' and TenantId eq '{tenantId.ToLowerInvariant().Replace("'", "''")}'";
/// <summary>Model → entity (every field in both directions; the offboarding wipe matches the lowercase TenantId).</summary>
internal static TableEntity Build(McpClientRegistration r) => new(PartitionKey, r.RegistrationId)
{
["RegistrationId"] = r.RegistrationId,
["TenantId"] = r.TenantId.ToLowerInvariant(),
["Name"] = r.Name,
["RedirectUri"] = r.RedirectUri,
["CreatedBy"] = r.CreatedBy,
["CreatedDate"] = new DateTimeOffset(DateTime.SpecifyKind(r.CreatedAt, DateTimeKind.Utc)),
};
internal static McpClientRegistration Map(TableEntity e) => new()
{
RegistrationId = e.GetString("RegistrationId") ?? e.RowKey,
TenantId = e.GetString("TenantId") ?? string.Empty,
Name = e.GetString("Name") ?? string.Empty,
RedirectUri = e.GetString("RedirectUri") ?? string.Empty,
CreatedBy = e.GetString("CreatedBy") ?? string.Empty,
CreatedAt = e.GetDateTimeOffset("CreatedDate")?.UtcDateTime ?? DateTime.MinValue,
};
}
}
@@ -74,6 +74,18 @@ public static class ClaimsPrincipalExtensions
public static bool IsApplicationPrincipal(this ClaimsPrincipal principal)
=> string.Equals(principal.FindFirst("idtyp")?.Value, "app", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// True for a delegated token from a foreign client application that the client-app binding admitted
/// (<see cref="Security.ClientAppBinding"/>): the person keeps their identity, but is capped like an
/// application principal.
/// </summary>
public static bool IsClientCapped(this ClaimsPrincipal principal)
=> Security.ClientAppBinding.IsCapped(principal);
/// <summary>Application principal or admitted foreign client: the callers every application cap applies to.</summary>
public static bool IsCappedPrincipal(this ClaimsPrincipal principal)
=> principal.IsApplicationPrincipal() || principal.IsClientCapped();
/// <summary>
/// The caller's rate-limit budget class (<see cref="Security.ThrottleSurface"/>), from signed claims
/// only: <c>appidacr</c> (v1.0) / <c>azpacr</c> (v2.0) say how the CLIENT authenticated — <c>0</c> is a
@@ -303,9 +303,9 @@ public class AutopilotDeviceValidationConsentFunction
}
/// <summary>
/// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog.
/// "device-preparation" is reserved for a future DevPrep-specific consent flow; it is
/// accepted by the success endpoint so the value can land in ops events when wired up.
/// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog:
/// one per serial-based validation (Autopilot, corporate identifier, and
/// "device-preparation" for Device Association Validation).
/// </summary>
internal static readonly HashSet<string> KnownConsentTriggers = new(StringComparer.OrdinalIgnoreCase)
{
@@ -0,0 +1,144 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin;
/// <summary>
/// Self-hosted MCP client registrations: Tenant Admins (or a Global Admin) manage the exact callbacks of
/// clients their organization runs itself; the MCP server's OAuth proxy resolves a client id
/// <c>amc_&lt;id&gt;</c> through the anonymous lookup. The lookup sits under <c>/api/auth</c> because the
/// Function App requires client certificates outside a fixed list of path prefixes.
/// </summary>
public class McpClientRegistrationFunction
{
/// <summary>Per source IP and minute. Every lookup comes from the MCP server's egress IP, which caches answers for a minute.</summary>
internal const int LookupRateLimitPerMinute = 120;
private readonly ILogger<McpClientRegistrationFunction> _logger;
private readonly McpClientRegistrationService _service;
private readonly RateLimitService _rateLimitService;
public McpClientRegistrationFunction(
ILogger<McpClientRegistrationFunction> logger,
McpClientRegistrationService service,
RateLimitService rateLimitService)
{
_logger = logger;
_service = service;
_rateLimitService = rateLimitService;
}
/// <summary>GET /api/tenants/{tenantId}/mcp-client-registrations</summary>
[Function("GetMcpClientRegistrations")]
[Authorize]
public async Task<HttpResponseData> List(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "tenants/{tenantId}/mcp-client-registrations")] HttpRequestData req,
string tenantId,
FunctionContext context)
{
var requestCtx = context.GetRequestContext();
var registrations = await _service.ListAsync(requestCtx.TargetTenantId);
return await req.OkAsync(new McpClientRegistrationListResponse
{
Enabled = await _service.IsEnabledAsync(),
MaxRegistrations = await _service.GetLimitAsync(requestCtx.TargetTenantId),
ServerUrl = $"{Constants.McpServerBaseUrl}/mcp",
Registrations = registrations.Select(ToItem).ToList(),
});
}
/// <summary>POST /api/tenants/{tenantId}/mcp-client-registrations</summary>
[Function("CreateMcpClientRegistration")]
[Authorize]
public async Task<HttpResponseData> Create(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "tenants/{tenantId}/mcp-client-registrations")] HttpRequestData req,
string tenantId,
FunctionContext context)
{
var requestCtx = context.GetRequestContext();
var read = await req.ReadAsync<CreateMcpClientRegistrationRequest>();
if (read.Error != null) return read.Error;
var result = await _service.CreateAsync(
requestCtx.TargetTenantId, read.Value!.Name, read.Value.RedirectUri, requestCtx.UserPrincipalName);
if (result.Registration == null)
{
var code = result.Status switch
{
HttpStatusCode.Forbidden => Constants.ApiErrorCodes.Forbidden,
HttpStatusCode.Conflict => Constants.ApiErrorCodes.Conflict,
HttpStatusCode.InternalServerError => Constants.ApiErrorCodes.InternalError,
_ => Constants.ApiErrorCodes.BadRequest,
};
return await req.ErrorAsync(result.Status, code, result.Error!);
}
return await req.CreatedAsync(new CreateMcpClientRegistrationResponse { Registration = ToItem(result.Registration) });
}
/// <summary>DELETE /api/tenants/{tenantId}/mcp-client-registrations/{registrationId}</summary>
[Function("DeleteMcpClientRegistration")]
[Authorize]
public async Task<HttpResponseData> Delete(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "tenants/{tenantId}/mcp-client-registrations/{registrationId}")] HttpRequestData req,
string tenantId,
string registrationId,
FunctionContext context)
{
var requestCtx = context.GetRequestContext();
var deleted = await _service.DeleteAsync(requestCtx.TargetTenantId, registrationId, requestCtx.UserPrincipalName);
return deleted
? await req.OkAsync(new MessageResponse { Message = "Registration deleted" })
: await req.NotFoundAsync("Registration not found");
}
/// <summary>
/// GET /api/auth/mcp/client-registrations/{registrationId} — anonymous; the MCP server's OAuth proxy
/// resolves a tenant-bound client id before any user token exists. 404 for an unknown id and while the
/// operator switch is off; the id is 128 random bits, the answer names nothing secret.
/// </summary>
[Function("LookupMcpClientRegistration")]
public async Task<HttpResponseData> Lookup(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/mcp/client-registrations/{registrationId}")] HttpRequestData req,
string registrationId)
{
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
var rateLimit = _rateLimitService.CheckRateLimit($"mcp-client-lookup:{clientIp}", LookupRateLimitPerMinute);
if (!rateLimit.IsAllowed)
{
return await req.ErrorAsync(HttpStatusCode.TooManyRequests, Constants.ApiErrorCodes.RateLimited,
"Rate limit exceeded. Try again later.",
retryAfterSeconds: rateLimit.RetryAfter is { } retryAfter ? (int)retryAfter.TotalSeconds : null);
}
var registration = await _service.LookupAsync(registrationId);
if (registration == null)
return await req.NotFoundAsync("Registration not found");
return await req.OkAsync(new McpClientRegistrationLookupResponse
{
RegistrationId = registration.RegistrationId,
TenantId = registration.TenantId,
RedirectUri = registration.RedirectUri,
Name = registration.Name,
});
}
internal static McpClientRegistrationItem ToItem(McpClientRegistration r) => new()
{
RegistrationId = r.RegistrationId,
ClientId = McpClientRegistrationService.ClientIdOf(r.RegistrationId),
Name = r.Name,
RedirectUri = r.RedirectUri,
CreatedBy = r.CreatedBy,
CreatedUtc = r.CreatedAt,
};
}
@@ -85,9 +85,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
// read the full config, so this boolean is their only signal. Deliberately exposes no
// destination detail — just "would an upload work".
DiagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
// Drives the "Autopilot Device Validation disabled" dashboard banner
// (useTenantSecurityConfig).
ValidateAutopilotDevice = config.ValidateAutopilotDevice,
// Drives the "no device validation enabled" dashboard banner (useTenantSecurityConfig).
DeviceValidationEnabled = config.HasAnyDeviceValidation(),
// Dual app-reg self-service migration: when true, running the consent flow (or
// "Detect existing access") targets the NEW app registration and auto-flips this
// tenant's homing after verification — drives the explanatory banner in the
@@ -128,7 +128,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute ||
config.CustomUserRateLimitRequestsPerMinute != existingConfig.CustomUserRateLimitRequestsPerMinute ||
config.Disabled != existingConfig.Disabled ||
config.McpDisabled != existingConfig.McpDisabled)
config.McpDisabled != existingConfig.McpDisabled ||
config.McpClientRegistrationLimit != existingConfig.McpClientRegistrationLimit)
{
_logger.LogWarning(
"Tenant Admin {User} attempted to modify GA-only fields for tenant {TenantId}",
@@ -148,6 +149,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.DisabledReason = existingConfig.DisabledReason;
config.DisabledUntil = existingConfig.DisabledUntil;
config.McpDisabled = existingConfig.McpDisabled;
config.McpClientRegistrationLimit = existingConfig.McpClientRegistrationLimit;
config.McpDisabledReason = existingConfig.McpDisabledReason;
}
@@ -129,6 +129,9 @@ public class GetGraphPermissionsStatusFunction
["W365CloudPcValidationGranted"] = GraphFeatureCatalog
.IsFeatureGranted(GraphFeatureCatalog.FeatureW365CloudPcValidation, snapshot.GrantedRoles)
.ToString(CultureInfo.InvariantCulture),
["IntuneDeviceBindingGranted"] = GraphFeatureCatalog
.IsFeatureGranted(GraphFeatureCatalog.FeatureIntuneDeviceBinding, snapshot.GrantedRoles)
.ToString(CultureInfo.InvariantCulture),
});
}
catch (Exception ex)
@@ -35,6 +35,7 @@ public class AuthFunction
private readonly EntraAppRegistry _appRegistry;
private readonly AdminIdentityResolver _identityResolver;
private readonly ISignalRNotificationService _signalRService;
private readonly AdminConfigurationService _adminConfigService;
public AuthFunction(
ILogger<AuthFunction> logger,
@@ -50,9 +51,11 @@ public class AuthFunction
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
EntraAppRegistry appRegistry,
AdminIdentityResolver identityResolver,
ISignalRNotificationService signalRService)
ISignalRNotificationService signalRService,
AdminConfigurationService adminConfigService)
{
_logger = logger;
_adminConfigService = adminConfigService;
_identityResolver = identityResolver;
_signalRService = signalRService;
_globalAdminService = globalAdminService;
@@ -169,7 +172,8 @@ public class AuthFunction
delegatedTenantIds,
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary",
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc);
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc,
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false);
if (!decision.IsSuccess)
{
@@ -555,7 +559,8 @@ public class AuthFunction
IReadOnlyCollection<string>? delegatedTenantIds = null,
string homedApp = "primary",
DateTime? whatsNewSeenPlatformUtc = null,
DateTime? whatsNewSeenAgentUtc = null)
DateTime? whatsNewSeenAgentUtc = null,
bool mcpClientRegistrationEnabled = false)
{
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
@@ -644,6 +649,7 @@ public class AuthFunction
UnrestrictedModeEnabled =
FeatureEntitlementCatalog.Get(TenantEntitlementService.Resolve(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
&& tenantConfig.UnrestrictedModeEnabled,
McpClientRegistrationEnabled = mcpClientRegistrationEnabled,
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc
}, needsAutoAdmin);
@@ -250,7 +250,8 @@ public class McpUserFunction
// the identity binding (never the UPN alone); the delegated seat additionally requires a Pro home tenant.
// The token's app roles feed the AllMembers member check (claim-derived Admin/Operator when the tenant opted in).
var result = await _mcpUserService.IsAllowedAsync(
upn, principal?.GetTenantId(), principal?.GetObjectId(), principal?.GetAppRoles());
upn, principal?.GetTenantId(), principal?.GetObjectId(), principal?.GetAppRoles(),
principal?.IsClientCapped() ?? false);
if (result.IsAllowed && principal != null && principal.IsApplicationPrincipal())
ObserveApplicationSession(principal, result);
@@ -143,7 +143,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
_logger.LogInformation($"Registering session {registration.SessionId} for tenant {registration.TenantId} (Device: {validation.CertificateThumbprint})");
// Server-populated: record which device-validation path accepted this request
// (AutopilotV1 / CorporateIdentifier / DeviceAssociation / Bootstrap) so the
// (AutopilotV1 / CorporateIdentifier / DeviceAssociation / Bootstrap / CloudPc /
// IntuneEnrollment) so the
// session row shows HOW the backend admitted the device. Overwrites any
// agent-sent value unconditionally — this is the server's verdict, not input.
registration.ValidatedBy = validation.ValidatedBy;
@@ -24,6 +24,12 @@ public sealed record RequestContext
/// <see cref="Security.AdminIdentity"/>; empty when the token carried none (app-only) or no JWT at all.</summary>
public string ObjectId { get; init; } = string.Empty;
/// <summary>
/// True when the caller's token was obtained by a foreign client application that the client-app
/// binding admitted; <see cref="Security.AdminIdentity.FromRequestContext"/> carries it into every cap.
/// </summary>
public bool ClientCapped { get; init; }
/// <summary>
/// Stable identity of the authenticated caller, for THROTTLING keys only — never authorization,
/// never audit (use <see cref="UserPrincipalName"/> there; this may be a bare object id).
@@ -158,6 +158,10 @@ namespace AutopilotMonitor.Functions.Helpers
if (customLimitError != null)
return $"{customLimitError} override must be at least 1 request per minute (or left blank to inherit the global default).";
if (candidate.McpClientRegistrationLimit is int registrations
&& (registrations < 1 || registrations > Services.McpClientRegistrationService.MaxRegistrationLimit))
return $"Self-hosted AI client registrations must be between 1 and {Services.McpClientRegistrationService.MaxRegistrationLimit} (or left blank for the default of {Services.McpClientRegistrationService.DefaultRegistrationLimit}).";
var contactEmailError = ValidateContactEmail(candidate.ContactEmail);
if (contactEmailError != null)
return $"Invalid contact email: {contactEmailError}";
@@ -169,11 +169,8 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
// EntraId:AdditionalClientIds. Folding the legacy id in here means configuring the
// legacy pair alone is sufficient for inbound trust — no separate AdditionalClientIds
// entry required. All extra sources unset ⇒ exactly the primary id ⇒ zero change.
var additionalClientIdsRaw = CombineAdditionalClientIdSources(
_configuration["EntraId:LegacyClientId"],
_configuration["EntraId:AdditionalClientIds"]);
var clientIds = ResolveConfiguredClientIds(
_configuration["EntraId:ClientId"], additionalClientIdsRaw, out var rejectedAdditionalEntries);
var clientIds = ResolveTrustedClientIds(
_configuration, out var additionalClientIdsRaw, out var rejectedAdditionalEntries);
AuditClientIdTrustSet(additionalClientIdsRaw, clientIds, rejectedAdditionalEntries);
var validationParameters = BuildTokenValidationParameters(
openIdConfig.SigningKeys, clientIds);
@@ -385,6 +382,21 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
return nonEmpty.Length > 0 ? string.Join(",", nonEmpty) : null;
}
/// <summary>
/// The platform's own app registrations: the audience trust set of this middleware, and the set
/// <see cref="ClientAppBindingMiddleware"/> treats as "not foreign". One resolver so the two can never
/// disagree about which applications are ours.
/// </summary>
internal static string[] ResolveTrustedClientIds(
IConfiguration configuration, out string? additionalClientIdsRaw, out string[] rejectedAdditionalEntries)
{
additionalClientIdsRaw = CombineAdditionalClientIdSources(
configuration["EntraId:LegacyClientId"],
configuration["EntraId:AdditionalClientIds"]);
return ResolveConfiguredClientIds(
configuration["EntraId:ClientId"], additionalClientIdsRaw, out rejectedAdditionalEntries);
}
internal static string[] ResolveConfiguredClientIds(
string? primaryClientId, string? additionalClientIds, out string[] rejectedAdditionalEntries)
{
@@ -0,0 +1,133 @@
using System.Collections.Concurrent;
using System.Net;
using System.Security.Claims;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Middleware;
/// <summary>
/// Measures and — behind <c>AdminConfiguration.EnforceClientAppBinding</c> — enforces the client-app binding
/// of delegated tokens (<see cref="ClientAppBinding"/>). Runs after <see cref="AuthenticationMiddleware"/>
/// (it reads the validated principal) and before <see cref="PolicyEnforcementMiddleware"/> (the cap marker
/// must exist before any role resolves). Tokens from the platform's own registrations cost one set lookup.
/// </summary>
public class ClientAppBindingMiddleware : IFunctionsWorkerMiddleware
{
private static readonly TimeSpan LogInterval = TimeSpan.FromHours(1);
private const int MaxLogKeys = 1000;
// Worker traces reach App Insights at Warning+ only; the request dimensions carry the per-request
// measurement, this line makes a new (tenant, client, outcome) visible without flooding.
private static readonly ConcurrentDictionary<string, DateTime> LastLogged = new();
private readonly ILogger<ClientAppBindingMiddleware> _logger;
private readonly IConfiguration _configuration;
private readonly TenantAdminsService _tenantAdminsService;
private readonly AdminConfigurationService _adminConfigService;
public ClientAppBindingMiddleware(
ILogger<ClientAppBindingMiddleware> logger,
IConfiguration configuration,
TenantAdminsService tenantAdminsService,
AdminConfigurationService adminConfigService)
{
_logger = logger;
_configuration = configuration;
_tenantAdminsService = tenantAdminsService;
_adminConfigService = adminConfigService;
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
// Only a principal the authentication step validated — never the empty httpContext.User of an
// anonymous or device route, which names no client and would read as "unidentified".
if (!context.Items.TryGetValue("ClaimsPrincipal", out var principalObj) || principalObj is not ClaimsPrincipal principal)
{
await next(context);
return;
}
var trustedClientIds = ResolveTrustedClientApps(_configuration);
if (!ClientAppBinding.IsForeignDelegatedToken(principal, trustedClientIds, out var clientAppId))
{
await next(context);
return;
}
var tenantId = principal.GetTenantId();
var registered = false;
if (clientAppId != null && !string.IsNullOrWhiteSpace(tenantId))
{
var (state, _) = await _tenantAdminsService.GetTableMembershipAsync(
tenantId, Constants.PrincipalKeys.ForApplication(clientAppId));
registered = state == TableMemberState.Enabled;
}
var outcome = clientAppId == null
? ClientAppBinding.Outcomes.Unidentified
: registered ? ClientAppBinding.Outcomes.Registered : ClientAppBinding.Outcomes.Unregistered;
context.Items[ClientAppBinding.OutcomeItemKey] = outcome;
if (clientAppId != null)
context.Items[ClientAppBinding.ClientAppIdItemKey] = clientAppId;
var enforced = (await _adminConfigService.GetConfigurationAsync()).EnforceClientAppBinding;
LogOncePerInterval(tenantId, clientAppId, outcome, enforced);
if (enforced)
{
if (!registered)
{
var httpContext = context.GetHttpContext();
if (httpContext != null)
{
await ApiErrorWriter.WriteAsync(
httpContext, context.GetCorrelationId(), HttpStatusCode.Forbidden,
Constants.ApiErrorCodes.ClientAppNotRegistered,
ClientAppBinding.RefusalMessage(clientAppId));
}
return;
}
ClientAppBinding.MarkCapped(principal, clientAppId!);
}
await next(context);
}
/// <summary>
/// The client applications whose delegated tokens are never foreign: the audience trust set (primary,
/// legacy, additional app registrations) plus <c>EntraId:TrustedClientAppIds</c> — clients of the platform's
/// own that obtain tokens for the API app without being an audience themselves (the local development
/// registration). The extra setting widens only this set, never the accepted token audiences.
/// </summary>
internal static IReadOnlyCollection<string> ResolveTrustedClientApps(IConfiguration configuration)
{
var audienceTrust = AuthenticationMiddleware.ResolveTrustedClientIds(configuration, out _, out _);
var clientOnly = AuthenticationMiddleware.ResolveConfiguredClientIds(
null, configuration["EntraId:TrustedClientAppIds"], out _);
return audienceTrust.Concat(clientOnly).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private void LogOncePerInterval(string? tenantId, string? clientAppId, string outcome, bool enforced)
{
var key = $"{tenantId}|{clientAppId}|{outcome}|{enforced}";
var now = DateTime.UtcNow;
if (LastLogged.TryGetValue(key, out var last) && now - last < LogInterval)
return;
if (LastLogged.Count >= MaxLogKeys)
LastLogged.Clear();
LastLogged[key] = now;
_logger.LogWarning(
"[ClientAppBinding] Delegated token from client app {ClientAppId} (tid={TenantId}): {Outcome}, enforced={Enforced}",
LogSanitizer.Clean(clientAppId ?? "(none)"), LogSanitizer.Clean(tenantId ?? "(none)"), outcome, enforced);
}
}
@@ -365,6 +365,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// decision.UserIdentifier for log lines, where "anonymous" is the useful rendering.
UserPrincipalName = upn ?? string.Empty,
ObjectId = principal?.GetObjectId() ?? string.Empty,
ClientCapped = principal?.IsClientCapped() ?? false,
// Throttle identity — NOT the same thing as the UPN. Empty only when the request carried
// no JWT at all (device/anonymous routes, which bring their own limits). An authenticated
// caller ALWAYS gets a non-empty value, including app-only tokens that carry no upn:
@@ -487,7 +488,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// "some authenticated person". The roleless tiers admit any valid token, so they are closed to
// applications unless the catalog entry opted in (auth/mcp). Every role-gated tier below already
// resolves the app:<client-id> key through the same tables as a person, with its caps applied.
if (principal != null && principal.IsApplicationPrincipal() && entry.IsRolelessTier && !entry.ApplicationAllowed)
// A person reached through an admitted foreign client is capped the same way.
if (principal != null && principal.IsCappedPrincipal() && entry.IsRolelessTier && !entry.ApplicationAllowed)
return CatalogDecisionResult.Deny(userIdentifier, "Application", "ApplicationPrincipalNotAllowed");
switch (entry.Policy)
@@ -697,7 +699,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// </summary>
private Task<MemberRoleInfo?> ResolveEffectiveRoleAsync(
string tenantId, string upn, ClaimsPrincipal? principal)
=> _memberRoleResolver.ResolveAsync(tenantId, upn, principal?.GetAppRoles());
=> _memberRoleResolver.ResolveAsync(tenantId, upn, principal?.GetAppRoles(), principal?.IsClientCapped() ?? false);
/// <summary>
/// Platform-wide cross-tenant READ: admits GlobalAdmin and the read-only GlobalReader. The
@@ -159,6 +159,13 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
requestTelemetry.Properties["ApplicationId"] = applicationId;
}
// CLIENT-APP BINDING — set by ClientAppBindingMiddleware only for a delegated token a foreign
// client application obtained: the measurement behind the enforcement switch (who would be refused).
if (context.Items.TryGetValue(ClientAppBinding.OutcomeItemKey, out var clientBinding) && clientBinding is string clientBindingOutcome)
requestTelemetry.Properties["ClientAppBinding"] = clientBindingOutcome;
if (context.Items.TryGetValue(ClientAppBinding.ClientAppIdItemKey, out var clientApp) && clientApp is string clientAppId)
requestTelemetry.Properties["ClientAppId"] = clientAppId;
if (context.Items.TryGetValue("CorrelationId", out var corrId) && corrId is string correlationId)
requestTelemetry.Properties["CorrelationId"] = correlationId;
@@ -191,6 +198,14 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
if (context.Items.TryGetValue(RequestRowMarkers.DeviceValidationKey, out var deviceValidation) && deviceValidation is string deviceValidationOutcome)
requestTelemetry.Properties[RequestRowMarkers.DeviceValidationKey] = deviceValidationOutcome;
// CERT-DEVICE-BINDING — the Intune device lookup behind the certificate (admitting or
// observing), set by SecurityValidator whenever a lookup result exists for the request.
foreach (var key in RequestRowMarkers.CertDeviceBindingKeys)
{
if (context.Items.TryGetValue(key, out var value) && value is string text)
requestTelemetry.Properties[key] = text;
}
var reqCtx = context.GetRequestContext();
var tenantId = ResolveTenantId(
reqCtx.TenantId,
@@ -41,6 +41,9 @@ builder.UseMiddleware<SecurityHeadersMiddleware>();
builder.UseMiddleware<NoStoreCacheMiddleware>();
builder.UseMiddleware<GlobalExceptionMiddleware>();
builder.UseMiddleware<AuthenticationMiddleware>();
// Delegated tokens from foreign client apps: measured always, refused or capped when enforced. Must run
// before policy enforcement, which resolves roles with the cap marker this step sets.
builder.UseMiddleware<ClientAppBindingMiddleware>();
builder.UseMiddleware<PolicyEnforcementMiddleware>();
builder.UseMiddleware<UserRateLimitMiddleware>();
// MCP quota (daily/monthly budget on top of the per-minute rate limit): only requests marked
@@ -267,6 +270,7 @@ builder.Services.AddSingleton<DelegatedAdminService>();
builder.Services.AddSingleton<DelegatedSlotService>();
builder.Services.AddSingleton<DelegationSelfService>();
builder.Services.AddSingleton<McpUserService>();
builder.Services.AddSingleton<McpClientRegistrationService>();
builder.Services.AddSingleton<PreviewWhitelistService>();
// Shared activation path (whitelist add + auto-promote + welcome email) — used by the
// Global Admin approve endpoint and the tenant auto-approve queue worker.
@@ -21,30 +21,36 @@ namespace AutopilotMonitor.Functions.Security;
/// <param name="TenantId">The JWT <c>tid</c> (lowercase) — the caller's home tenant.</param>
/// <param name="ObjectId">The JWT <c>oid</c> (lowercase) — the caller's object id in that tenant (for an
/// application the service principal's object id, which differs per tenant).</param>
public sealed record AdminIdentity(string Upn, string TenantId, string ObjectId)
/// <param name="ClientCapped">True for a person whose token a foreign client application obtained and the
/// client-app binding admitted (<see cref="ClientAppBinding"/>).</param>
public sealed record AdminIdentity(string Upn, string TenantId, string ObjectId, bool ClientCapped = false)
{
/// <summary>
/// True when the key names an application (app-only token). Applications are capped everywhere: never
/// a platform role, never more than Viewer in a tenant, never more than DelegatedReader on a managed
/// tenant — the caps live in the respective role services, this flag is what they switch on.
/// tenant — the caps live in the respective role services, <see cref="IsCapped"/> is what they switch on.
/// </summary>
public bool IsApplication => Shared.Constants.PrincipalKeys.IsApplication(Upn);
/// <summary>The application caps apply: an application principal, or a person reached through a foreign client.</summary>
public bool IsCapped => IsApplication || ClientCapped;
/// <summary>Builds the identity from a validated principal, or null when upn, tid or oid is missing.</summary>
public static AdminIdentity? FromPrincipal(ClaimsPrincipal? principal)
=> principal == null
? null
: Create(principal.GetUserPrincipalName(), principal.GetTenantId(), principal.GetObjectId());
: Create(principal.GetUserPrincipalName(), principal.GetTenantId(), principal.GetObjectId(),
principal.IsClientCapped());
/// <summary>Builds the identity from the middleware-resolved request context, or null when incomplete.</summary>
public static AdminIdentity? FromRequestContext(RequestContext context)
=> Create(context.UserPrincipalName, context.TenantId, context.ObjectId);
=> Create(context.UserPrincipalName, context.TenantId, context.ObjectId, context.ClientCapped);
/// <summary>Normalizing factory: all three parts required (whitespace counts as missing), all lowercased.</summary>
public static AdminIdentity? Create(string? upn, string? tenantId, string? objectId)
public static AdminIdentity? Create(string? upn, string? tenantId, string? objectId, bool clientCapped = false)
{
if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(objectId))
return null;
return new AdminIdentity(upn.ToLowerInvariant(), tenantId.ToLowerInvariant(), objectId.ToLowerInvariant());
return new AdminIdentity(upn.ToLowerInvariant(), tenantId.ToLowerInvariant(), objectId.ToLowerInvariant(), clientCapped);
}
}
@@ -268,6 +268,7 @@ namespace AutopilotMonitor.Functions.Security
Thumbprint = thumbprint,
Subject = certificate.Subject,
Issuer = certificate.Issuer,
NotBefore = new DateTimeOffset(notBeforeUtc, TimeSpan.Zero),
CertTenantId = certTenantId,
CertTenantIdStatus = certTenantIdStatus
};
@@ -412,6 +413,12 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Start of the certificate's validity (UTC), set on success. The Intune MDM Device CA
/// issues the certificate at enrollment, so its age bounds how recently the device enrolled.
/// </summary>
public DateTimeOffset? NotBefore { get; set; }
/// <summary>
/// Entra TenantId stamped into the certificate by the Intune MDM Device CA
/// (OID <c>1.2.840.113556.5.14</c>), or <c>null</c> when
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using AutopilotMonitor.Functions.Extensions;
namespace AutopilotMonitor.Functions.Security;
/// <summary>
/// Client-app binding for delegated (user) tokens. Entra issues a token for this API to any application a
/// tenant consented to; the token names that application in <c>azp</c> (v2.0) / <c>appid</c> (v1.0). A token
/// obtained by one of the platform's own registrations (the audience trust set: primary, legacy and
/// additional client ids) is the portal or the MCP server and passes untouched. Any other client — a
/// self-hosted AI client using on-behalf-of, an in-house tool — is "foreign": it is measured on every
/// request and, while enforcement is on, admitted only when that application is an enabled member of the
/// caller's tenant (the <c>app:&lt;client-id&gt;</c> row a service principal is granted under), with the
/// caller capped like an application principal (<see cref="AdminIdentity.IsCapped"/>).
/// </summary>
public static class ClientAppBinding
{
/// <summary><c>FunctionContext.Items</c> key: the foreign client's application id (absent when the token names none).</summary>
public const string ClientAppIdItemKey = "ClientAppId";
/// <summary><c>FunctionContext.Items</c> key: one of <see cref="Outcomes"/> — set only for a foreign client.</summary>
public const string OutcomeItemKey = "ClientAppBinding";
/// <summary>
/// Authentication type of the marker identity added to an admitted foreign-client principal. A token
/// cannot produce it: every token-derived identity carries the handler's authentication type, so the
/// marker is only ever the middleware's own statement.
/// </summary>
public const string CappedAuthenticationType = "AutopilotMonitor.ClientAppBinding";
/// <summary>Claim on the marker identity naming the admitted client application.</summary>
public const string ClientAppClaimType = "client_app";
public static class Outcomes
{
/// <summary>The application is an enabled member of the caller's tenant.</summary>
public const string Registered = "registered";
/// <summary>The application is not a member of the caller's tenant, or its row is disabled.</summary>
public const string Unregistered = "unregistered";
/// <summary>The token names no client application at all (never seen from Entra; fail-closed when enforced).</summary>
public const string Unidentified = "unidentified";
}
/// <summary>
/// True when <paramref name="principal"/> is a delegated token obtained by a client outside
/// <paramref name="trustedClientIds"/>. App-only tokens are never foreign here — they have their own gate
/// (<c>access_as_application</c>) and are keyed on their own member row. <paramref name="clientAppId"/> is
/// the token's client id (lowercase) or null when it names none.
/// </summary>
public static bool IsForeignDelegatedToken(
ClaimsPrincipal principal, IReadOnlyCollection<string> trustedClientIds, out string? clientAppId)
{
clientAppId = null;
if (principal.IsApplicationPrincipal())
return false;
clientAppId = principal.GetApplicationId();
return clientAppId == null
|| !trustedClientIds.Contains(clientAppId, StringComparer.OrdinalIgnoreCase);
}
/// <summary>Marks an admitted foreign-client principal so every role resolution applies the application caps.</summary>
public static void MarkCapped(ClaimsPrincipal principal, string clientAppId)
=> principal.AddIdentity(new ClaimsIdentity(
new[] { new Claim(ClientAppClaimType, clientAppId) }, CappedAuthenticationType));
/// <summary>True when the middleware admitted this principal as a foreign client (see <see cref="MarkCapped"/>).</summary>
public static bool IsCapped(ClaimsPrincipal principal)
=> principal.Identities.Any(i => string.Equals(i.AuthenticationType, CappedAuthenticationType, StringComparison.Ordinal));
/// <summary>The 403 text for a refused foreign client: names the application and the one fix.</summary>
public static string RefusalMessage(string? clientAppId)
=> clientAppId == null
? "This token names no client application and is not accepted. Sign in through the Autopilot Monitor portal or a supported MCP client."
: $"The application {clientAppId} that obtained this token is not registered in your organization. " +
"A Tenant Admin can add it under Settings > Access Management as a service principal; its users then have read-only (Viewer) access.";
}
@@ -377,6 +377,10 @@ public static class EndpointAccessPolicyCatalog
new("GET", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("DELETE", "tenants/{tenantId}/admins/{adminUpn}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
// Self-hosted MCP client registrations (the OAuth callback a tenant's own client uses).
new("GET", "tenants/{tenantId}/mcp-client-registrations", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "tenants/{tenantId}/mcp-client-registrations", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("DELETE", "tenants/{tenantId}/mcp-client-registrations/{registrationId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/disable", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/enable", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/permissions", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
@@ -415,6 +419,9 @@ public static class EndpointAccessPolicyCatalog
// The MCP front door: the only roleless-tier route an application principal may reach — the
// handler resolves membership / delegation itself (McpUserService.IsAllowedAsync).
new("GET", "auth/mcp", EndpointPolicy.AuthenticatedUser, applicationAllowed: true),
// The MCP server's OAuth proxy resolves a tenant-bound client id before any user token exists:
// anonymous, per-IP rate-limited in the function, 404 while the operator switch is off.
new("GET", "auth/mcp/client-registrations/{registrationId}", EndpointPolicy.PublicAnonymous),
// ── MCP Usage (self-service) ──────────────────────────────────
new("GET", "metrics/mcp-usage/me", EndpointPolicy.AuthenticatedUser),
@@ -1,6 +1,10 @@
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Graph;
using System;
using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -17,55 +21,97 @@ namespace AutopilotMonitor.Functions.Security
/// <para>
/// <see cref="CertTenantBinding"/> already proves the certificate was issued to this tenant.
/// This adds the second half: that the specific device is still one of the tenant's enrolled
/// devices, so a certificate lifted from a decommissioned or wiped machine no longer resolves.
/// devices. That is proof enough to admit a device without any pre-registration, which is
/// what the Intune Enrollment Validation option does (<see cref="ValidateAsync"/>). For
/// devices admitted by another validator the same lookup only observes
/// (<see cref="ObserveInBackground"/>).
/// </para>
/// </summary>
/// <remarks>
/// Built as a near-sibling of <see cref="CloudPcDeviceValidator"/> — same DI dependencies and
/// the same cache/retry/transient contract (30 min positive, 5 min negative, 2 attempts with a
/// 2s back-off, transient on Graph 5xx / token failure / exception). Two deliberate differences:
/// Same cache/retry/budget contract as <see cref="DeviceAssociationValidator"/> (30 min
/// positive, 5 min negative, 2 attempts inside the chain budget, transient never cached), with
/// three deliberate differences:
/// <list type="bullet">
/// <item><description>A point-GET on <c>managedDevices/{id}</c> rather than an OData
/// <c>$filter</c>. There is no filter literal to inject into, and a 404 is an unambiguous
/// <c>$filter</c> (D-125): no filter literal to inject into, and a 404 is an unambiguous
/// "not this tenant's device".</description></item>
/// <item><description><see cref="IntuneDeviceBindingResult.EnrolledDateTime"/> is carried out
/// so callers can record how old the device object was at request time. That is the open
/// question this check must answer before it could ever gate enrollment: a device whose object
/// appears only after the agent's first call is a race, not an attack.</description></item>
/// <item><description>A 404 for a certificate younger than <see cref="RecentEnrollmentWindow"/>
/// is <see cref="IntuneDeviceBindingOutcome.NotFoundRecentEnrollment"/>: the device object may
/// simply not be visible in Graph yet, so the caller answers 503 Retry-After instead of 403 and
/// nothing is cached.</description></item>
/// <item><description>Observation never runs without the Graph permission: whether the tenant
/// granted it is read from the cached app token's roles claim, so a tenant without the add-on
/// costs no Graph call at all.</description></item>
/// </list>
/// SHADOW ONLY (stage 3): nothing consumes the result for an authorization decision. Grep
/// marker for the enforcement change: <c>CERT-DEVICE-BINDING-SHADOW</c>.
/// </remarks>
public class IntuneDeviceBindingValidator
{
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
/// <summary>
/// How long after the certificate's NotBefore a 404 counts as "not visible yet" rather than
/// "not this tenant's device". Generous on purpose: the window only turns a 403 into a 503
/// and never admits anything, and whether Intune backdates NotBefore is unmeasured
/// (rejected devices reported their first call at least ~12 min after NotBefore).
/// </summary>
internal static readonly TimeSpan RecentEnrollmentWindow = TimeSpan.FromMinutes(60);
/// <summary>How long a tenant's permission verdict is reused before asking the detector again.</summary>
private static readonly TimeSpan PermissionVerdictTtl = TimeSpan.FromMinutes(2);
/// <summary>Upper bound for one background observation, so an in-flight marker cannot outlive a stuck call.</summary>
private static readonly TimeSpan InFlightTtl = TimeSpan.FromMinutes(1);
/// <summary>
/// Pause after an observation that ended transient (nothing was cached), so a device whose
/// object is not visible yet costs one Graph call per interval, not one per request.
/// </summary>
private static readonly TimeSpan TransientObservationCooldown = TimeSpan.FromSeconds(30);
/// <summary>
/// managementState values of a device that is being removed from management. Such a device
/// still has an object, but it is on its way out, so it must not be admitted. Every other
/// state (managed, unhealthy, discovered, *Failed, *Canceled) is a device Intune still manages.
/// </summary>
private static readonly HashSet<string> LeavingManagementStates = new(StringComparer.OrdinalIgnoreCase)
{
"retirePending", "retireIssued", "wipePending", "wipeIssued", "deletePending",
};
private readonly ILogger<IntuneDeviceBindingValidator> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly GraphTokenService _graphTokenService;
private readonly IGraphFeatureDetector _graphFeatureDetector;
public IntuneDeviceBindingValidator(
ILogger<IntuneDeviceBindingValidator> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
GraphTokenService graphTokenService)
GraphTokenService graphTokenService,
IGraphFeatureDetector graphFeatureDetector)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_graphTokenService = graphTokenService;
_graphFeatureDetector = graphFeatureDetector;
}
/// <summary>
/// Resolves the Intune device id (from the client certificate's Subject CN) against the
/// tenant's managedDevices inventory. Never throws.
/// tenant's managedDevices inventory. Runs regardless of whether the permission was
/// detected: an admitting call must be able to pick up a freshly granted permission through
/// the 401/403 token refresh (<see cref="GraphAuthFailure"/>). Never throws.
/// </summary>
public async Task<IntuneDeviceBindingResult> ValidateAsync(
public virtual async Task<IntuneDeviceBindingResult> ValidateAsync(
string tenantId,
string? intuneDeviceId,
string? sessionId = null)
DateTimeOffset? certNotBefore,
IntuneDeviceBindingRole role,
string? sessionId = null,
CancellationToken ct = default)
{
// The id becomes a URL path segment. The strict GUID gate keeps it from being anything
// else - the same rule the rest of the security path applies.
@@ -89,30 +135,119 @@ namespace AutopilotMonitor.Functions.Security
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
var result = await TryValidateViaGraphAsync(tenantId, normalizedId, sessionId, cacheKey, attempt);
var result = await TryValidateViaGraphAsync(tenantId, normalizedId, certNotBefore, cacheKey, attempt, ct);
if (!result.IsTransient)
{
LogLookup(result, tenantId, certNotBefore, role, sessionId);
return result;
}
lastTransient = result;
if (attempt < maxAttempts)
{
_logger.LogWarning(
"Intune device binding transient failure for tenant {TenantId}, deviceId {IntuneDeviceId} (attempt {Attempt}/{MaxAttempts}). Retrying...",
tenantId, normalizedId, attempt, maxAttempts);
await Task.Delay(TimeSpan.FromSeconds(2));
}
// Budget spent (chain token cancelled): no second attempt on a request the agent
// has abandoned - the transient result becomes the 503 Retry-After.
if (attempt == maxAttempts || ct.IsCancellationRequested)
break;
await DelayBeforeRetryAsync(ct);
}
LogLookup(lastTransient!, tenantId, certNotBefore, role, sessionId);
return lastTransient!;
}
private async Task<IntuneDeviceBindingResult> TryValidateViaGraphAsync(
string tenantId, string normalizedId, string? sessionId, string cacheKey, int attempt)
/// <summary>
/// The cached result for this device, if a lookup already ran. Lets the caller stamp the
/// observation on the request row without waiting for Graph.
/// </summary>
public virtual IntuneDeviceBindingResult? TryGetCached(string tenantId, string? intuneDeviceId)
{
if (!SecurityValidator.IsValidGuid(intuneDeviceId))
return null;
return _cache.TryGetValue(BuildCacheKey(tenantId, intuneDeviceId!.Trim().ToLowerInvariant()), out IntuneDeviceBindingResult? cached)
? cached?.AsCacheHit()
: null;
}
/// <summary>
/// Observation for a device another validator admitted: fills the cache in the background so
/// later requests can stamp the outcome, without adding Graph latency to an admitted request.
/// Does nothing when the tenant has not granted DeviceManagementManagedDevices.Read.All
/// (read from the cached app token, no Graph call), when the result is already cached, or
/// when a lookup for the device is already running. Never throws.
/// </summary>
public virtual void ObserveInBackground(string tenantId, string? intuneDeviceId, DateTimeOffset? certNotBefore, string? sessionId)
{
if (!SecurityValidator.IsValidGuid(intuneDeviceId))
return;
var normalizedId = intuneDeviceId!.Trim().ToLowerInvariant();
var cacheKey = BuildCacheKey(tenantId, normalizedId);
var permissionKey = BuildPermissionKey(tenantId);
if (_cache.TryGetValue(cacheKey, out _))
return;
if (_cache.TryGetValue(permissionKey, out bool granted) && !granted)
return;
var inFlightKey = cacheKey + ":inflight";
if (_cache.TryGetValue(inFlightKey, out _))
return;
_cache.Set(inFlightKey, true, InFlightTtl);
_ = Task.Run(async () =>
{
var cooldown = false;
try
{
using var budget = DeviceValidationBudget.CreateChainCts();
if (!_cache.TryGetValue(permissionKey, out bool hasPermission))
{
hasPermission = await _graphFeatureDetector.HasPermissionAsync(
tenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, budget.Token);
_cache.Set(permissionKey, hasPermission, PermissionVerdictTtl);
}
if (hasPermission)
{
var result = await ValidateAsync(tenantId, normalizedId, certNotBefore, IntuneDeviceBindingRole.Observing, sessionId, budget.Token);
cooldown = result.IsTransient;
}
}
catch (Exception ex)
{
cooldown = true;
_logger.LogWarning(ex, "AgentCertDeviceBinding observation failed for tenant {TenantId} - ignored.", tenantId);
}
finally
{
// A transient outcome caches nothing, so the in-flight marker doubles as the
// retry cooldown; otherwise the cached result answers every later request.
if (cooldown)
_cache.Set(inFlightKey, true, TransientObservationCooldown);
else
_cache.Remove(inFlightKey);
}
});
}
/// <summary>Retry pause that yields early when the chain budget runs out (never throws).</summary>
private static async Task DelayBeforeRetryAsync(CancellationToken ct)
{
try { await Task.Delay(TimeSpan.FromSeconds(2), ct); }
catch (OperationCanceledException) { }
}
private async Task<IntuneDeviceBindingResult> TryValidateViaGraphAsync(
string tenantId, string normalizedId, DateTimeOffset? certNotBefore, string cacheKey, int attempt, CancellationToken chain)
{
using var attemptCts = DeviceValidationBudget.CreateAttemptCts(chain);
var ct = attemptCts.Token;
try
{
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId);
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
if (string.IsNullOrEmpty(tokenResult.AccessToken))
{
return new IntuneDeviceBindingResult
@@ -128,16 +263,26 @@ namespace AutopilotMonitor.Functions.Security
// Point-GET: normalizedId passed the GUID gate, so the path segment is safe.
var graphUrl = $"{Constants.GraphBaseUrl}/v1.0/deviceManagement/managedDevices/{normalizedId}"
+ "?$select=id,deviceName,enrolledDateTime,azureADDeviceId,managementState";
+ "?$select=id,deviceName,enrolledDateTime,azureADDeviceId,managementState,"
+ "serialNumber,managedDeviceOwnerType,deviceEnrollmentType,azureADRegistered";
var response = await graphClient.GetAsync(graphUrl);
var responseBody = await response.Content.ReadAsStringAsync();
var response = await graphClient.GetAsync(graphUrl, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Definitive: this tenant has no managedDevice with that id. Either the device
// belongs elsewhere, or its object has not appeared yet - the race this shadow
// pass exists to measure.
// A young certificate means the device may simply not be visible yet: say so and
// cache nothing, so the next attempt sees the object as soon as it appears.
if (IsRecentEnrollment(certNotBefore, DateTimeOffset.UtcNow))
{
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFoundRecentEnrollment,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not visible in Intune yet (recent enrollment)"
};
}
return CacheAndReturn(cacheKey, new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound,
@@ -146,8 +291,8 @@ namespace AutopilotMonitor.Functions.Security
}, isPositive: false);
}
// 401/403 on attempt 1: the cached token may predate the tenant's consent (see
// GraphAuthFailure) — drop it and let the loop retry with a fresh one before the
// 401/403 on attempt 1: the cached token may predate the tenant's grant (see
// GraphAuthFailure) - drop it and let the loop retry with a fresh one before the
// definitive "not granted" verdict below is cached against a stale token.
if (GraphAuthFailure.TryRecoverStaleToken(_graphTokenService, _logger, nameof(IntuneDeviceBindingValidator), tenantId, response.StatusCode, attempt))
{
@@ -159,7 +304,7 @@ namespace AutopilotMonitor.Functions.Security
};
}
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
if (GraphAuthFailure.IsAuthFailure(response.StatusCode))
{
GraphAuthFailure.LogPermissionMissing(_logger, nameof(IntuneDeviceBindingValidator), tenantId, response.StatusCode);
// Configuration state, not an outage: retrying cannot fix a missing grant.
@@ -189,12 +334,22 @@ namespace AutopilotMonitor.Functions.Security
var result = ParseManagedDeviceResponse(responseBody, normalizedId);
return CacheAndReturn(cacheKey, result, isPositive: result.IsValid);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Budget exhausted (per-attempt or chain) - transient, never cached.
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Transient,
IntuneDeviceId = normalizedId,
ErrorMessage = "Managed device lookup timed out"
};
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error during Intune device binding for tenant {TenantId}, session {SessionId}, deviceId {IntuneDeviceId} (attempt {Attempt})",
tenantId, sessionId ?? "<none>", normalizedId, attempt);
"Error during Intune device binding for tenant {TenantId}, deviceId {IntuneDeviceId} (attempt {Attempt})",
tenantId, normalizedId, attempt);
return new IntuneDeviceBindingResult
{
@@ -205,10 +360,37 @@ namespace AutopilotMonitor.Functions.Security
}
}
/// <summary>
/// One Warning line per real Graph lookup (never for cache hits: one enrollment produced 136
/// requests but a single Graph call). Carries the two ages that answer the enrollment-race
/// question: how old the device object and the certificate were at lookup time.
/// </summary>
private void LogLookup(IntuneDeviceBindingResult result, string tenantId, DateTimeOffset? certNotBefore, IntuneDeviceBindingRole role, string? sessionId)
{
var now = DateTimeOffset.UtcNow;
var enrolledAgeSeconds = result.EnrolledDateTime.HasValue
? (long)Math.Round((now - result.EnrolledDateTime.Value).TotalSeconds)
: -1;
var certAgeSeconds = certNotBefore.HasValue
? (long)Math.Round((now - certNotBefore.Value).TotalSeconds)
: -1;
_logger.LogWarning(
"AgentCertDeviceBinding outcome={Outcome} role={Role} tenant={TenantId} "
+ "certDeviceId={CertDeviceId} device={DeviceName} enrolledAgeSeconds={EnrolledAgeSeconds} "
+ "certAgeSeconds={CertAgeSeconds} mgmtState={ManagementState} ownerType={OwnerType} "
+ "enrollmentType={EnrollmentType} session={SessionId} detail={Detail}",
result.Outcome, role, tenantId,
result.IntuneDeviceId ?? "n/a", result.DeviceName ?? "n/a", enrolledAgeSeconds,
certAgeSeconds, result.ManagementState ?? "n/a", result.OwnerType ?? "n/a",
result.EnrollmentType ?? "n/a", sessionId ?? "n/a", result.ErrorMessage ?? "n/a");
}
/// <summary>
/// Pure-function: maps a <c>managedDevices/{id}</c> Graph response body to a result.
/// Re-checks the returned <c>id</c> so a redirected or widened lookup can never produce a
/// false positive.
/// false positive, and turns a device on its way out of management into
/// <see cref="IntuneDeviceBindingOutcome.NotManaged"/>.
/// </summary>
internal static IntuneDeviceBindingResult ParseManagedDeviceResponse(string responseBody, string normalizedId)
{
@@ -236,17 +418,36 @@ namespace AutopilotMonitor.Functions.Security
return notFound;
}
var managementState = device?["managementState"]?.ToString();
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Match,
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = normalizedId,
DeviceName = device?["deviceName"]?.ToString(),
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
ManagementState = device?["managementState"]?.ToString(),
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.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
};
}
/// <summary>
/// True when the certificate was issued less than <see cref="RecentEnrollmentWindow"/> ago.
/// An unknown NotBefore is never recent: without it there is no evidence of a race.
/// </summary>
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
@@ -269,9 +470,14 @@ namespace AutopilotMonitor.Functions.Security
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)
=> $"intune-device-binding:{tenantId}:{intuneDeviceId}";
internal static string BuildPermissionKey(string tenantId)
=> $"intune-device-binding-permission:{tenantId}";
private IntuneDeviceBindingResult CacheAndReturn(
string cacheKey, IntuneDeviceBindingResult result, bool isPositive)
{
@@ -283,6 +489,13 @@ namespace AutopilotMonitor.Functions.Security
}
}
/// <summary>Why a lookup ran: to admit a device, or to observe one another validator admitted.</summary>
public enum IntuneDeviceBindingRole
{
Admitting = 0,
Observing = 1
}
/// <summary>
/// Stable outcome codes for the device-binding check. Emitted verbatim in the
/// <c>AgentCertDeviceBinding</c> log and as the <c>CertDeviceBinding</c> request dimension —
@@ -290,10 +503,10 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
public enum IntuneDeviceBindingOutcome
{
/// <summary>Device id from the certificate resolves to a managedDevice in this tenant.</summary>
/// <summary>Device id from the certificate resolves to a managed device in this tenant.</summary>
Match = 0,
/// <summary>No managedDevice with that id in this tenant (foreign device, or not yet created).</summary>
/// <summary>No managedDevice with that id in this tenant, and the certificate is not recent.</summary>
NotFound = 1,
/// <summary>Certificate subject carried no usable Intune device id.</summary>
@@ -302,8 +515,14 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>DeviceManagementManagedDevices.Read.All not granted in this tenant.</summary>
PermissionMissing = 3,
/// <summary>Graph outage, token failure or exception — says nothing about the device.</summary>
Transient = 4
/// <summary>Graph outage, token failure, timeout or exception — says nothing about the device.</summary>
Transient = 4,
/// <summary>The device exists but is being retired, wiped or deleted.</summary>
NotManaged = 5,
/// <summary>No managedDevice yet, but the certificate is recent: the object may not be visible yet.</summary>
NotFoundRecentEnrollment = 6
}
public class IntuneDeviceBindingResult
@@ -314,10 +533,11 @@ namespace AutopilotMonitor.Functions.Security
public bool IsValid => Outcome == IntuneDeviceBindingOutcome.Match;
/// <summary>
/// True when the failure says nothing about the device (Graph error, token issue).
/// Transient results are never cached.
/// True when the failure says nothing definitive about the device (Graph error, token
/// issue, or a device object that may not be visible yet). Transient results are never cached.
/// </summary>
public bool IsTransient => Outcome == IntuneDeviceBindingOutcome.Transient;
public bool IsTransient => Outcome == IntuneDeviceBindingOutcome.Transient
|| Outcome == IntuneDeviceBindingOutcome.NotFoundRecentEnrollment;
/// <summary>Intune device id taken from the client certificate's Subject CN.</summary>
public string? IntuneDeviceId { get; set; }
@@ -328,9 +548,21 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>Entra device id of the same device, if Intune knows one — diagnostic only.</summary>
public string? AzureAdDeviceId { get; set; }
/// <summary>Intune management state — diagnostic only.</summary>
/// <summary>Intune management state; a leaving state turns a match into <see cref="IntuneDeviceBindingOutcome.NotManaged"/>.</summary>
public string? ManagementState { get; set; }
/// <summary>Serial number Intune recorded for the device — compared against the agent's serial header.</summary>
public string? SerialNumber { get; set; }
/// <summary>Intune ownership (<c>company</c>, <c>personal</c>, <c>unknown</c>) — telemetry only.</summary>
public string? OwnerType { get; set; }
/// <summary>Intune deviceEnrollmentType (e.g. <c>windowsAzureADJoin</c>) — telemetry only.</summary>
public string? EnrollmentType { get; set; }
/// <summary>True for a workplace-joined (Entra registered) device — telemetry only.</summary>
public bool? AzureAdRegistered { get; set; }
/// <summary>
/// When Intune recorded the enrollment. The age of this value at request time is what
/// tells a genuine race (object created seconds ago) apart from a foreign certificate.
@@ -341,9 +573,6 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// True when this result came from the in-memory cache rather than a fresh Graph lookup.
/// Callers use it to log once per real lookup instead of once per request: a single
/// enrollment produced 136 requests but only one Graph call, so logging per request said
/// the same thing 136 times.
/// </summary>
public bool ServedFromCache { get; set; }
@@ -358,6 +587,10 @@ namespace AutopilotMonitor.Functions.Security
DeviceName = DeviceName,
AzureAdDeviceId = AzureAdDeviceId,
ManagementState = ManagementState,
SerialNumber = SerialNumber,
OwnerType = OwnerType,
EnrollmentType = EnrollmentType,
AzureAdRegistered = AzureAdRegistered,
EnrolledDateTime = EnrolledDateTime,
ErrorMessage = ErrorMessage,
ServedFromCache = true
@@ -41,6 +41,66 @@ namespace AutopilotMonitor.Functions.Security
public static string DeviceValidationValue(ValidatorType validatedBy)
=> validatedBy == ValidatorType.Unknown ? DeviceValidation.None : validatedBy.ToString();
/// <summary>
/// Outcome of the Intune device lookup behind the client certificate
/// (<see cref="IntuneDeviceBindingOutcome"/> name). Absent when no lookup result exists for
/// the request: the tenant granted no permission, or an observation is still running.
/// </summary>
public const string CertDeviceBindingKey = "CertDeviceBinding";
/// <summary>
/// <see cref="IntuneDeviceBindingRole"/> of that lookup: <c>Admitting</c> when it decided
/// admission, <c>Observing</c> when another validator had already admitted the device.
/// </summary>
public const string CertDeviceBindingRoleKey = "CertDeviceBindingRole";
/// <summary>
/// <c>true</c> / <c>false</c>: whether the agent's serial header equals the serial Intune
/// recorded for the certificate's device. Absent when either side is unknown. Observation
/// only: nothing rejects on it.
/// </summary>
public const string CertDeviceSerialMatchKey = "CertDeviceSerialMatch";
/// <summary>Intune managedDeviceOwnerType of the certificate's device (company / personal / unknown).</summary>
public const string CertDeviceOwnerTypeKey = "CertDeviceOwnerType";
/// <summary>Intune deviceEnrollmentType of the certificate's device.</summary>
public const string CertDeviceEnrollmentTypeKey = "CertDeviceEnrollmentType";
/// <summary>Every cert-device key; <c>RequestTelemetryMiddleware</c> copies them onto the request row.</summary>
public static readonly string[] CertDeviceBindingKeys =
{
CertDeviceBindingKey, CertDeviceBindingRoleKey, CertDeviceSerialMatchKey,
CertDeviceOwnerTypeKey, CertDeviceEnrollmentTypeKey,
};
/// <summary>
/// Stamps one Intune device lookup onto the request row. <paramref name="headerSerial"/> is
/// the agent-supplied serial, compared case-insensitively against the one Intune recorded.
/// </summary>
public static void StampCertDeviceBinding(
HttpRequestData req, IntuneDeviceBindingResult result, IntuneDeviceBindingRole role, string? headerSerial)
{
Stamp(req, CertDeviceBindingKey, result.Outcome.ToString());
Stamp(req, CertDeviceBindingRoleKey, role.ToString());
var serialMatch = SerialMatch(headerSerial, result.SerialNumber);
if (serialMatch.HasValue)
Stamp(req, CertDeviceSerialMatchKey, serialMatch.Value ? "true" : "false");
if (!string.IsNullOrEmpty(result.OwnerType))
Stamp(req, CertDeviceOwnerTypeKey, result.OwnerType!);
if (!string.IsNullOrEmpty(result.EnrollmentType))
Stamp(req, CertDeviceEnrollmentTypeKey, result.EnrollmentType!);
}
/// <summary>Null when either serial is unknown; otherwise a trimmed, case-insensitive comparison.</summary>
internal static bool? SerialMatch(string? headerSerial, string? intuneSerial)
{
if (string.IsNullOrWhiteSpace(headerSerial) || string.IsNullOrWhiteSpace(intuneSerial))
return null;
return string.Equals(headerSerial!.Trim(), intuneSerial!.Trim(), System.StringComparison.OrdinalIgnoreCase);
}
public static void Stamp(HttpRequestData req, string key, string value)
{
var items = req.FunctionContext?.Items;
@@ -291,14 +291,14 @@ namespace AutopilotMonitor.Functions.Security
// Security validation is always enforced (no longer configurable per tenant)
// Hard gate: tenant must enable at least one device validation method before agent traffic is accepted.
// Global Admins can set AllowInsecureAgentRequests=true in the config row for test tenants.
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.ValidateDeviceAssociation && !config.ValidateCloudPcDevice && !config.AllowInsecureAgentRequests)
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests)
{
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Forbidden,
ErrorMessage = "Device validation is required",
Details = "Enable 'Autopilot Device Validation', 'Corporate Identifier Validation', 'Device Association Validation' or 'Windows 365 Cloud PC Validation' in Configuration before using the agent ingestion endpoints."
Details = "Enable at least one device validation method (Autopilot, Corporate Identifier, Device Association, Windows 365 Cloud PC or Intune Enrollment) in Configuration before using the agent ingestion endpoints."
};
}
@@ -413,6 +413,8 @@ namespace AutopilotMonitor.Functions.Security
string? serialNumber = req.Headers.Contains("X-Device-SerialNumber")
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
: null;
// TLS-proven device identity: the Intune managedDevice id in the certificate CN.
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
string? autopilotDeviceId = null;
bool deviceValidated = false;
bool deviceValidationTransient = false;
@@ -494,8 +496,7 @@ namespace AutopilotMonitor.Functions.Security
// ends in the 403 below.
if (!deviceValidated && config.ValidateCloudPcDevice && _cloudPcDeviceValidator != null)
{
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var intuneDeviceId);
var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, intuneDeviceId, sessionId, validationCt);
var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, certIntuneDeviceId, sessionId, validationCt);
if (cloudPcResult.IsValid)
{
deviceValidated = true;
@@ -508,7 +509,44 @@ namespace AutopilotMonitor.Functions.Security
}
}
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier || config.ValidateDeviceAssociation || config.ValidateCloudPcDevice) && !deviceValidated)
// Intune Enrollment Validation — the last accepting method and the only one that needs no
// pre-registration: the certificate's Intune device id must resolve to a device this
// tenant still manages. Same TLS-proven identity as the Cloud PC stage, so nothing here
// depends on a header. Runs even when the permission was not detected, so a freshly
// granted one is picked up through the 401/403 token refresh instead of after the
// detector's token cache expires.
if (!deviceValidated && config.ValidateIntuneDeviceBinding && _intuneDeviceBindingValidator != null)
{
var binding = await _intuneDeviceBindingValidator.ValidateAsync(
tenantId, certIntuneDeviceId, certValidation.NotBefore, IntuneDeviceBindingRole.Admitting, sessionId, validationCt);
RequestRowMarkers.StampCertDeviceBinding(req, binding, IntuneDeviceBindingRole.Admitting, serialNumber);
if (binding.IsValid)
{
deviceValidated = true;
validatedBy = ValidatorType.IntuneEnrollment;
}
else
{
deviceValidationError = CombineValidationErrors(deviceValidationError, binding.ErrorMessage);
// Transient covers a Graph outage and a device object that is not visible yet
// (recent certificate). A missing grant is a configuration gap, not evidence
// about the device, so it is answered like the missing core consent of the
// Autopilot and Device Association lookups (503, 120 s) rather than as
// "device not registered" (GraphAuthFailure).
if (binding.IsTransient)
{
deviceValidationTransient = true;
}
else if (binding.Outcome == IntuneDeviceBindingOutcome.PermissionMissing)
{
deviceValidationTransient = true;
deviceValidationRetryAfter = MaxRetryAfter(deviceValidationRetryAfter, GraphAuthFailure.PermissionMissingRetryAfterSeconds);
}
}
}
if (config.HasAnyDeviceValidation() && !deviceValidated)
{
// Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries
// Definitive failures (device not registered) → 403 Forbidden
@@ -542,74 +580,23 @@ namespace AutopilotMonitor.Functions.Security
};
}
// 5. CERT-DEVICE-BINDING-SHADOW - cert-to-device binding (Global-Admin-only preview).
// Stage 1 (CertTenantBinding) proves the certificate was issued to THIS tenant; this
// proves the specific device is one the tenant actually enrolled, so a certificate
// lifted from a decommissioned machine stops resolving. Observation only: it never
// touches deviceValidated or the returned result.
//
// Fire-and-forget: a cold Graph round-trip can take seconds and an observation-only
// check must never sit in the agent's request path. That rules out the request-row
// dimension stage 1 uses (the row is written the moment the function returns, before
// this task finishes), so the outcome is logged at Warning instead (one line per real
// Graph lookup, see ServedFromCache). Volume is bounded: only Global Admins can enable the
// toggle. Widening it beyond preview should move to an inline call plus the
// CertTenantBinding-style request dimension.
if (config.ValidateIntuneDeviceBinding && _intuneDeviceBindingValidator != null)
// 5. CERT-DEVICE-BINDING — observation for a device another validator admitted (or an
// AllowInsecureAgentRequests tenant). Never admits, never rejects, and never waits for
// Graph: the request row gets the cached outcome, and a cache miss starts a background
// lookup — only when the tenant granted DeviceManagementManagedDevices.Read.All.
if (validatedBy != ValidatorType.IntuneEnrollment && _intuneDeviceBindingValidator != null)
{
var bindingValidator = _intuneDeviceBindingValidator;
var bindingTenant = tenantId;
var bindingSession = sessionId;
var bindingSubject = certValidation.Subject;
var bindingThumbprint = certValidation.Thumbprint;
var bindingLogger = _logger;
_ = Task.Run(async () =>
{
try
{
TryGetIntuneDeviceIdFromCertSubject(bindingSubject, out var certDeviceId);
var binding = await bindingValidator.ValidateAsync(bindingTenant, certDeviceId, bindingSession);
// One line per real Graph lookup, not per request. Measured on the first
// live enrollment: 136 requests produced exactly ONE Graph call (the 30 min
// positive cache did its job), so per-request logging repeated the same
// finding 136 times. Cached repeats add nothing - the age just counts up
// mechanically from the same enrolledDateTime. Uncached outcomes
// (Transient, NoDeviceIdInCert) still log every time, which is what we want
// for anomalies.
if (binding.ServedFromCache)
return;
// Age of the device object at request time: the discriminator between a
// genuine enrollment race (object created moments ago, or not yet) and a
// certificate that never belonged to this tenant.
var enrolledAgeSeconds = binding.EnrolledDateTime.HasValue
? (long)Math.Round((DateTimeOffset.UtcNow - binding.EnrolledDateTime.Value).TotalSeconds)
: -1;
bindingLogger.LogWarning(
"AgentCertDeviceBinding outcome={Outcome} enforced={Enforced} tenant={TenantId} "
+ "certDeviceId={CertDeviceId} device={DeviceName} enrolledAgeSeconds={EnrolledAgeSeconds} "
+ "mgmtState={ManagementState} thumbprint={Thumbprint} session={SessionId} detail={Detail}",
binding.Outcome, false, bindingTenant,
certDeviceId ?? "n/a", binding.DeviceName ?? "n/a", enrolledAgeSeconds,
binding.ManagementState ?? "n/a", bindingThumbprint ?? "n/a",
bindingSession ?? "n/a", binding.ErrorMessage ?? "n/a");
}
catch (Exception ex)
{
bindingLogger.LogWarning(ex,
"AgentCertDeviceBinding (shadow) failed for tenant {TenantId} - ignored.", bindingTenant);
}
});
var observed = _intuneDeviceBindingValidator.TryGetCached(tenantId, certIntuneDeviceId);
if (observed != null)
RequestRowMarkers.StampCertDeviceBinding(req, observed, IntuneDeviceBindingRole.Observing, serialNumber);
else
_intuneDeviceBindingValidator.ObserveInBackground(tenantId, certIntuneDeviceId, certValidation.NotBefore, sessionId);
}
// All checks passed. The admitting validator rides on the request row: the Sessions
// row keeps ValidatedBy per session, this is the per-request counter and timestamp.
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey,
RequestRowMarkers.DeviceValidationValue(validatedBy));
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
return new SecurityValidationResult
{
IsValid = true,
@@ -626,7 +613,8 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// Joins the per-validator failure messages so the 403 Details / rejection log carries
/// every miss in chain order (Autopilot | CorporateIdentifier | CloudPc), not just the
/// every miss in chain order (Autopilot | CorporateIdentifier | DeviceAssociation | CloudPc |
/// IntuneEnrollment), not just the
/// last validator's.
/// </summary>
internal static string? CombineValidationErrors(string? accumulated, string? next)
@@ -59,4 +59,18 @@ public class TenantMemberRoleResolver
var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
return EntraAppRoleResolver.Resolve(state, tableRole, appRoles, config.EntraAppRolesEnabled);
}
/// <summary>
/// <see cref="ResolveAsync(string, string, IReadOnlyList{string}?)"/> with the application cap applied
/// when <paramref name="clientCapped"/>: a person reached through an admitted foreign client keeps
/// their membership but never acts above Viewer and never manages bootstrap tokens.
/// </summary>
public async Task<MemberRoleInfo?> ResolveAsync(
string tenantId, string upn, IReadOnlyList<string>? appRoles, bool clientCapped)
{
var role = await ResolveAsync(tenantId, upn, appRoles);
return clientCapped && role != null
? new MemberRoleInfo { Role = Constants.TenantRoles.Viewer, CanManageBootstrapTokens = false }
: role;
}
}
@@ -85,7 +85,7 @@ public class DelegatedAdminService
// An application principal reads managed tenants, it never administers them: whatever role its rows
// carry (a GA grant on global/delegated-admins could say DelegatedAdmin), the scope it presents is
// capped to DelegatedReader. Enforced at resolution so no grant path has to remember the rule.
return identity.IsApplication ? gated.AsReaderOnly() : gated;
return identity.IsCapped ? gated.AsReaderOnly() : gated;
}
/// <summary>The scope the assignment ROWS (direct + group-derived) confer on a UPN, ignoring identity binding
@@ -75,8 +75,8 @@ public class GlobalAdminService
// An application principal never holds a platform role (D-133): a GlobalAdmins row keyed
// app:<client-id> is inert even if one were written. Tenant membership and delegation are the only
// grant paths for a service.
if (identity.IsApplication)
// grant paths for a service. A person reached through a foreign client app is capped the same way.
if (identity.IsCapped)
return null;
var role = await GetRowRoleAsync(identity.Upn);
@@ -0,0 +1,170 @@
using System.Net;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Self-hosted MCP client registrations (see <see cref="IMcpClientRegistrationRepository"/>): validation,
/// the per-tenant cap, the operator switch and the audit trail. The MCP server's OAuth proxy resolves a
/// client id <c>amc_&lt;id&gt;</c> through <see cref="LookupAsync"/> and binds the flow to the registering tenant.
/// </summary>
public class McpClientRegistrationService
{
/// <summary>Registrations a tenant may hold unless a Global Admin raised its limit on request.</summary>
public const int DefaultRegistrationLimit = 1;
/// <summary>Upper bound of the per-tenant override (<c>TenantConfiguration.McpClientRegistrationLimit</c>).</summary>
public const int MaxRegistrationLimit = 10;
public const int MaxNameLength = 64;
/// <summary>Mirrors the MCP proxy's redirect_uri length limit (oauth-limits.ts).</summary>
public const int MaxRedirectUriLength = 1024;
public const string ClientIdPrefix = "amc_";
private const string AuditEntity = "McpClientRegistration";
private static readonly Regex RegistrationIdPattern = new("^[0-9a-f]{32}$", RegexOptions.CultureInvariant);
private readonly IMcpClientRegistrationRepository _repo;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly AdminConfigurationService _adminConfigService;
private readonly TenantConfigurationService _tenantConfigService;
private readonly ILogger<McpClientRegistrationService> _logger;
public McpClientRegistrationService(
IMcpClientRegistrationRepository repo,
IMaintenanceRepository maintenanceRepo,
AdminConfigurationService adminConfigService,
TenantConfigurationService tenantConfigService,
ILogger<McpClientRegistrationService> logger)
{
_repo = repo;
_maintenanceRepo = maintenanceRepo;
_adminConfigService = adminConfigService;
_tenantConfigService = tenantConfigService;
_logger = logger;
}
/// <summary>The tenant's registration limit: its Global-Admin override, else the default of one.</summary>
public virtual async Task<int> GetLimitAsync(string tenantId)
{
var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId.ToLowerInvariant());
return config.McpClientRegistrationLimit is int limit && limit >= 1 ? Math.Min(limit, MaxRegistrationLimit) : DefaultRegistrationLimit;
}
/// <summary>The operator switch (5-minute cached admin configuration).</summary>
public virtual async Task<bool> IsEnabledAsync()
=> (await _adminConfigService.GetConfigurationAsync()).McpClientRegistrationEnabled;
public static string ClientIdOf(string registrationId) => ClientIdPrefix + registrationId;
/// <summary>Null when the name is acceptable, else the reason.</summary>
public static string? ValidateName(string? name)
{
var trimmed = name?.Trim() ?? string.Empty;
if (trimmed.Length == 0) return "A name is required.";
if (trimmed.Length > MaxNameLength) return $"The name may have at most {MaxNameLength} characters.";
if (trimmed.Any(char.IsControl)) return "The name contains control characters.";
return null;
}
/// <summary>
/// Null when the callback is acceptable, else the reason: an absolute https URL, or http on a loopback host,
/// with no query, fragment, user info or wildcard — the MCP proxy redirects authorization codes to it.
/// </summary>
public static string? ValidateRedirectUri(string? redirectUri)
{
var value = redirectUri?.Trim() ?? string.Empty;
if (value.Length == 0) return "A callback URL is required.";
if (value.Length > MaxRedirectUriLength) return $"The callback URL may have at most {MaxRedirectUriLength} characters.";
if (value.Contains('*')) return "The callback URL must be exact; wildcards are not allowed.";
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) return "The callback URL is not a valid absolute URL.";
var loopback = uri.Host is "localhost" or "127.0.0.1" or "[::1]";
if (uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && loopback))
return "The callback URL must use https (http is allowed only for localhost).";
if (!string.IsNullOrEmpty(uri.Query)) return "The callback URL must not contain a query.";
if (!string.IsNullOrEmpty(uri.Fragment) || value.Contains('#')) return "The callback URL must not contain a fragment.";
if (!string.IsNullOrEmpty(uri.UserInfo)) return "The callback URL must not contain user information.";
return null;
}
public static bool IsRegistrationId(string? registrationId)
=> registrationId != null && RegistrationIdPattern.IsMatch(registrationId);
public virtual async Task<List<McpClientRegistration>> ListAsync(string tenantId)
=> (await _repo.GetForTenantAsync(tenantId)).OrderBy(r => r.CreatedAt).ToList();
/// <summary>Creates a registration for <paramref name="tenantId"/>; the result names the HTTP status to answer.</summary>
public virtual async Task<McpClientRegistrationResult> CreateAsync(string tenantId, string? name, string? redirectUri, string actor)
{
if (!await IsEnabledAsync())
return McpClientRegistrationResult.Fail(HttpStatusCode.Forbidden, "Self-hosted MCP client registration is not enabled on this platform.");
var nameError = ValidateName(name);
if (nameError != null) return McpClientRegistrationResult.Fail(HttpStatusCode.BadRequest, nameError);
var uriError = ValidateRedirectUri(redirectUri);
if (uriError != null) return McpClientRegistrationResult.Fail(HttpStatusCode.BadRequest, uriError);
var tenant = tenantId.ToLowerInvariant();
var callback = redirectUri!.Trim();
var existing = await _repo.GetForTenantAsync(tenant);
var limit = await GetLimitAsync(tenant);
if (existing.Count >= limit)
return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict,
$"This tenant can register {limit} self-hosted AI client{(limit == 1 ? "" : "s")}. Delete one first, or ask us to raise the limit.");
if (existing.Any(r => string.Equals(r.RedirectUri, callback, StringComparison.OrdinalIgnoreCase)))
return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict, "This callback URL is already registered.");
var registration = new McpClientRegistration
{
RegistrationId = Guid.NewGuid().ToString("N"),
TenantId = tenant,
Name = name!.Trim(),
RedirectUri = callback,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
};
if (!await _repo.CreateAsync(registration))
return McpClientRegistrationResult.Fail(HttpStatusCode.InternalServerError, "The registration could not be stored.");
await _maintenanceRepo.LogAuditEntryAsync(tenant, "CREATE", AuditEntity, registration.RegistrationId, actor,
new Dictionary<string, string> { { "Name", registration.Name }, { "RedirectUri", registration.RedirectUri } });
_logger.LogWarning("MCP client registration {RegistrationId} created for tenant {TenantId} by {Actor}",
registration.RegistrationId, tenant, actor);
return McpClientRegistrationResult.Ok(registration);
}
/// <summary>
/// Deletes one of <paramref name="tenantId"/>'s registrations — also while the switch is off, so a tenant can
/// always clean up. False when the id is unknown or belongs to another tenant.
/// </summary>
public virtual async Task<bool> DeleteAsync(string tenantId, string registrationId, string actor)
{
if (!IsRegistrationId(registrationId)) return false;
var registration = await _repo.GetAsync(registrationId);
if (registration == null || !string.Equals(registration.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
return false;
if (!await _repo.DeleteAsync(registrationId)) return false;
await _maintenanceRepo.LogAuditEntryAsync(registration.TenantId, "DELETE", AuditEntity, registrationId, actor,
new Dictionary<string, string> { { "Name", registration.Name }, { "RedirectUri", registration.RedirectUri } });
_logger.LogWarning("MCP client registration {RegistrationId} deleted for tenant {TenantId} by {Actor}",
registrationId, registration.TenantId, actor);
return true;
}
/// <summary>The MCP proxy's lookup: the registration, or null when unknown or while the switch is off.</summary>
public virtual async Task<McpClientRegistration?> LookupAsync(string registrationId)
{
if (!IsRegistrationId(registrationId)) return null;
if (!await IsEnabledAsync()) return null;
return await _repo.GetAsync(registrationId);
}
}
/// <summary>Outcome of <see cref="McpClientRegistrationService.CreateAsync"/>.</summary>
public sealed record McpClientRegistrationResult(McpClientRegistration? Registration, HttpStatusCode Status, string? Error)
{
public static McpClientRegistrationResult Ok(McpClientRegistration registration) => new(registration, HttpStatusCode.Created, null);
public static McpClientRegistrationResult Fail(HttpStatusCode status, string error) => new(null, status, error);
}
@@ -87,7 +87,8 @@ public class McpUserService
/// <paramref name="appRoles"/> claim — a null tenant id is no member of anything.
/// </summary>
public virtual async Task<McpAccessCheckResult> IsAllowedAsync(
string? upn, string? homeTenantId, string? objectId, IReadOnlyList<string>? appRoles = null)
string? upn, string? homeTenantId, string? objectId, IReadOnlyList<string>? appRoles = null,
bool clientCapped = false)
{
// The principal key: a person's UPN, or app:<client-id> for an application principal. A token
// that yields neither carries no identity we could grant anything to.
@@ -95,7 +96,7 @@ public class McpUserService
return McpAccessCheckResult.Denied("Missing principal identity (no upn / preferred_username, and not an app-only token)");
upn = upn.ToLowerInvariant();
var identity = AdminIdentity.Create(upn, homeTenantId, objectId);
var identity = AdminIdentity.Create(upn, homeTenantId, objectId, clientCapped);
// Always resolve the platform role — needed by the MCP server for cross-tenant routing
// decisions (global scope → /api/global/* with tenantId-as-filter; non-global → /api/* JWT-bound).
@@ -184,7 +185,7 @@ public class McpUserService
// end-users), resolves to no role and is denied here instead of failing on every tool call.
var memberRole = string.IsNullOrWhiteSpace(homeTenantId)
? null
: await _memberRoleResolver.ResolveAsync(homeTenantId, upn, appRoles);
: await _memberRoleResolver.ResolveAsync(homeTenantId, upn, appRoles, clientCapped);
if (memberRole != null)
return McpAccessCheckResult.Allowed(upn, "AllMembers", false, null, delegatedTenantIds, delegatedRole);
return McpAccessCheckResult.Denied(Constants.PrincipalKeys.IsApplication(upn)
@@ -142,6 +142,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
(Constants.TableNames.BootstrapSessions, "CodeLookup"),
(Constants.TableNames.SessionReports, "reports"),
(Constants.TableNames.RuleSubmissions, "submissions"),
(Constants.TableNames.McpClientRegistrations, "registrations"),
};
// Plan §6.3.2 — Variant C (TenantId property only).
@@ -195,8 +195,8 @@ namespace AutopilotMonitor.Functions.Services
if (!string.IsNullOrEmpty(completionSource))
indexEntity["CompletionSource"] = completionSource;
// Device-validation path recorded at registration (AutopilotV1 / CorporateIdentifier /
// DeviceAssociation / Bootstrap) — index-served session reads must carry it too.
// Device-validation path recorded at registration (ValidatorType name) — index-served
// session reads must carry it too.
var validatedBy = sessionEntity.GetString("ValidatedBy");
if (!string.IsNullOrEmpty(validatedBy))
indexEntity["ValidatedBy"] = validatedBy;
@@ -148,6 +148,7 @@ namespace AutopilotMonitor.Functions.Services
"EnableEspContinueAnywayObservation",
"CustomRateLimitRequestsPerMinute", "CustomUserRateLimitRequestsPerMinute",
"MaxNdjsonPayloadSizeMB",
"McpClientRegistrationLimit",
};
/// <summary>
@@ -89,6 +89,52 @@ describe('accessGuard — a backend 401 becomes 401 + challenge', () => {
expect(out.body).toMatchObject({ error: 'Invalid token', reason: 'Token signature is invalid' });
});
it('names the token audience, client application and tenant in the log line (sanitized)', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
stubBackend(401, { error: 'Token signature is invalid', code: 'unauthorized' });
seed += 1;
const token = makeToken({
upn: `user${seed}@contoso.com`,
exp: Math.floor(Date.now() / 1000) + 3600,
aud: 'api://11111111-1111-1111-1111-111111111111',
azp: '22222222-2222-2222-2222-222222222222',
tid: '33333333-3333-3333-3333-333333333333\n',
});
const req = {
headers: { host: 'mcp.example.com', authorization: `Bearer ${token}` },
protocol: 'https',
body: { jsonrpc: '2.0', method: 'initialize', id: 1 },
} as unknown as Request;
const out = await run(req);
expect(out.status).toBe(401);
const line = spy.mock.calls.map((c) => String(c[0])).find((l) => l.includes('backend-rejected-token'));
expect(line).toBeDefined();
expect(line).toContain('method=initialize');
expect(line).toContain('aud=api://11111111-1111-1111-1111-111111111111');
expect(line).toContain('azp=22222222-2222-2222-2222-222222222222');
expect(line).toContain('tid=33333333-3333-3333-3333-333333333333');
expect(line).not.toContain('\n');
});
it('falls back to appid for a v1.0 token and prints "-" for a missing claim', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
stubBackend(401, { error: 'Token signature is invalid', code: 'unauthorized' });
seed += 1;
const token = makeToken({
upn: `user${seed}@contoso.com`,
exp: Math.floor(Date.now() / 1000) + 3600,
appid: '44444444-4444-4444-4444-444444444444',
});
const req = {
headers: { host: 'mcp.example.com', authorization: `Bearer ${token}` },
protocol: 'https',
body: { jsonrpc: '2.0', method: 'initialize', id: 1 },
} as unknown as Request;
await run(req);
const line = spy.mock.calls.map((c) => String(c[0])).find((l) => l.includes('backend-rejected-token'));
expect(line).toContain('aud=- azp=44444444-4444-4444-4444-444444444444 tid=-');
});
it('caches the rejection per token: a replay costs no second backend call and stays 401', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const fetchMock = stubBackend(401, { error: 'Token expired' });
@@ -10,6 +10,10 @@
* suites. No network is reached: every request fails validation (unsupported
* grant / missing code) before the handler would call Entra, and the limiter
* runs before the handler anyway.
*
* The last suite is the coverage check CodeQL's js/missing-rate-limiting stood
* in for (excluded, D-281): it cannot recognize these limiters, so every route
* of the OAuth router must carry one or be named as exempt here.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
@@ -20,6 +24,7 @@ process.env.AUTOPILOT_ENTRA_CLIENT_ID ??= '00000000-0000-0000-0000-000000000000'
process.env.MCP_OAUTH_RATE_LIMIT_PER_MINUTE = '3';
process.env.MCP_OAUTH_TOKEN_RATE_LIMIT_PER_MINUTE = '2';
const { createOAuthRouter } = await import('../oauth.js');
const { oauthRateLimit, oauthTokenRateLimit } = await import('../access-guard.js');
let server: Server;
let baseUrl: string;
@@ -89,3 +94,44 @@ describe('/oauth/* per-source-IP rate limiting (R-3)', () => {
expect((await token()).status).toBe(429);
});
});
describe('every OAuth router route is throttled (D-281)', () => {
// The discovery documents are the only exemption: static JSON built from config and the request
// host, no outbound call, no state, no logging. Anything else needs a limiter as its first handler.
const UNTHROTTLED = [
'GET /.well-known/oauth-protected-resource/mcp',
'GET /.well-known/oauth-protected-resource',
'GET /.well-known/oauth-authorization-server',
];
/** Each route of the OAuth router as `METHOD path` → the handler that runs first. */
function firstHandlers(): Map<string, unknown> {
const routes = new Map<string, unknown>();
for (const layer of createOAuthRouter().stack) {
// A router.use() middleware has no route and would escape the check below.
expect(layer.route, 'the OAuth router mounts routes only').toBeDefined();
for (const handler of layer.route!.stack) {
const key = `${(handler.method ?? 'all').toUpperCase()} ${layer.route!.path}`;
if (!routes.has(key)) routes.set(key, handler.handle);
}
}
return routes;
}
it('runs a per-IP limiter before every handler except the discovery documents', () => {
const routes = firstHandlers();
// Introspection sanity: the known surface is found (an Express upgrade that moves the stack fails here).
expect([...routes.keys()]).toEqual(expect.arrayContaining([
...UNTHROTTLED, 'POST /oauth/register', 'GET /oauth/authorize', 'GET /oauth/callback', 'POST /oauth/token',
]));
const unthrottled = [...routes].filter(([key, first]) =>
!UNTHROTTLED.includes(key) && first !== oauthRateLimit && first !== oauthTokenRateLimit,
).map(([key]) => key);
expect(unthrottled).toEqual([]);
});
it('gives /oauth/token the strict budget — it is the route that calls Entra with the client secret', () => {
expect(firstHandlers().get('POST /oauth/token')).toBe(oauthTokenRateLimit);
});
});
@@ -84,6 +84,12 @@ describe('/.well-known/oauth-authorization-server — RFC 8414 metadata', () =>
expect(json.issuer).toBe(baseUrl);
expect(json.authorization_response_iss_parameter_supported).toBe(true);
});
it('advertises public clients only, so clients never pick a secret-based token auth method', async () => {
const res = await fetch(`${baseUrl}/.well-known/oauth-authorization-server`);
const json = (await res.json()) as Record<string, unknown>;
expect(json.token_endpoint_auth_methods_supported).toEqual(['none']);
});
});
describe('/oauth/register — dynamic client registration (RFC 7591)', () => {
@@ -99,6 +105,19 @@ describe('/oauth/register — dynamic client registration (RFC 7591)', () => {
expect(json.token_endpoint_auth_method).toBe('none');
});
it('answers a requested secret-based auth method with none and issues no secret', async () => {
// A self-hosted client following the RFC 8414 default registers with client_secret_basic;
// echoing it back made the client demand a secret at the token endpoint.
const { status, json } = await register({
client_name: 'Self-hosted client',
redirect_uris: ['http://localhost:3080/api/mcp/example/oauth/callback'],
token_endpoint_auth_method: 'client_secret_basic',
});
expect(status).toBe(201);
expect(json.token_endpoint_auth_method).toBe('none');
expect(json.client_secret).toBeUndefined();
});
it('registers a VS Code client (loopback + vscode.dev redirect) — GitHub Copilot DCR', async () => {
const { status, json } = await register({
client_name: 'Visual Studio Code',
@@ -117,12 +136,23 @@ describe('/oauth/register — dynamic client registration (RFC 7591)', () => {
});
it('rejects a hostile redirect_uri host (allowlist defense-in-depth)', async () => {
const { status, json } = await register({
client_name: 'evil',
redirect_uris: ['https://attacker.tld/cb'],
});
expect(status).toBe(400);
expect(json.error).toBe('invalid_redirect_uri');
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const { status, json } = await register({
client_name: 'evil',
redirect_uris: ['https://attacker.tld/cb?state=secret'],
});
expect(status).toBe(400);
expect(json.error).toBe('invalid_redirect_uri');
// The log names the rejected target (origin + path) so the operator can judge a legitimate
// self-hosted client's allowlist request — and never the query.
const line = spy.mock.calls.map((c) => String(c[0])).find((l) => l.includes('[oauth/register] Rejected client'));
expect(line).toContain('evil');
expect(line).toContain('https://attacker.tld/cb');
expect(line).not.toContain('secret');
} finally {
spy.mockRestore();
}
});
it('rejects more than MAX_REDIRECT_URIS_PER_CLIENT redirect_uris', async () => {
@@ -0,0 +1,213 @@
/**
* Tenant-bound clients (portal registrations): a self-hosted client presents client_id
* `amc_<registrationId>`. The registration is its allowlist entry, the flow runs at the registering
* tenant's Entra authority, and the token endpoint discards a token for any other tenant. The backend
* lookup and Entra are stubbed; the router runs for real on an ephemeral server.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
process.env.AUTOPILOT_ENTRA_CLIENT_ID ??= '00000000-0000-0000-0000-000000000000';
process.env.MCP_OAUTH_RATE_LIMIT_PER_MINUTE = '100000';
process.env.MCP_OAUTH_TOKEN_RATE_LIMIT_PER_MINUTE = '100000';
const { createOAuthRouter, signState, clearTenantClientCache, tenantClientRegistrationId } = await import('../oauth.js');
const { API_BASE_URL } = await import('../config.js');
const REG_ID = '0123456789abcdef0123456789abcdef';
const CLIENT_ID = `amc_${REG_ID}`;
const TENANT = '11111111-1111-1111-1111-111111111111';
const OTHER_TENANT = '22222222-2222-2222-2222-222222222222';
const CALLBACK = 'https://chat.contoso.example/api/mcp/autopilot-monitor/oauth/callback';
let server: Server;
let baseUrl: string;
const realFetch = globalThis.fetch;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(createOAuthRouter());
await new Promise<void>((resolve) => { server = app.listen(0, '127.0.0.1', () => resolve()); });
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
afterEach(() => {
vi.restoreAllMocks();
clearTenantClientCache();
});
function jwt(claims: Record<string, unknown>): string {
const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
return `${enc({ alg: 'RS256', typ: 'JWT' })}.${enc(claims)}.sig`;
}
type Outbound = { url: string; body?: string };
/**
* Stubs the two outbound calls the proxy makes — the backend registration lookup and the Entra token
* endpoint — and lets requests to the local test server through.
*/
function stubOutbound(opts: { lookup: 'found' | 'missing' | 'error'; tokenTid?: string }) {
const outbound: Outbound[] = [];
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (url.startsWith(baseUrl)) return realFetch(input, init);
outbound.push({ url, body: typeof init?.body === 'string' ? init.body : undefined });
if (url.startsWith(`${API_BASE_URL}/api/auth/mcp/client-registrations/`)) {
if (opts.lookup === 'error') return new Response('boom', { status: 500 });
if (opts.lookup === 'missing') return new Response(JSON.stringify({ error: 'not found', code: 'NotFound' }), { status: 404 });
return new Response(JSON.stringify({ registrationId: REG_ID, tenantId: TENANT, redirectUri: CALLBACK, name: 'Team chat' }), { status: 200 });
}
if (url.includes('/oauth2/v2.0/token')) {
return new Response(JSON.stringify({
token_type: 'Bearer', expires_in: 3600, refresh_token: 'rt',
access_token: jwt({ tid: opts.tokenTid ?? TENANT, aud: 'api://x', upn: 'alice@contoso.example' }),
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected outbound call ${url}`);
});
return outbound;
}
function authorizeUrl(clientId: string, redirectUri: string): string {
const q = new URLSearchParams({
response_type: 'code', client_id: clientId, redirect_uri: redirectUri, state: 's1',
code_challenge: 'x'.repeat(43), code_challenge_method: 'S256',
});
return `${baseUrl}/oauth/authorize?${q}`;
}
async function token(form: Record<string, string>) {
const res = await fetch(`${baseUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(form).toString(),
});
return { status: res.status, json: (await res.json()) as Record<string, unknown> };
}
describe('tenant client ids', () => {
it('recognises only amc_ plus 32 lowercase hex', () => {
expect(tenantClientRegistrationId(CLIENT_ID)).toBe(REG_ID);
expect(tenantClientRegistrationId(`amc_${REG_ID.toUpperCase()}`)).toBeNull();
expect(tenantClientRegistrationId(`amc_${REG_ID}0`)).toBeNull();
expect(tenantClientRegistrationId('eyJhbGciOi.sig')).toBeNull();
expect(tenantClientRegistrationId(undefined)).toBeNull();
});
});
describe('/oauth/authorize with a tenant client', () => {
it('admits the registered callback although its host is on no allowlist, at the tenant authority', async () => {
const outbound = stubOutbound({ lookup: 'found' });
const res = await fetch(authorizeUrl(CLIENT_ID, CALLBACK), { redirect: 'manual' });
expect(res.status).toBe(302);
const location = new URL(res.headers.get('location')!);
expect(location.pathname).toBe(`/${TENANT}/oauth2/v2.0/authorize`);
expect(location.pathname).not.toContain('organizations');
expect(outbound.filter((o) => o.url.includes('/client-registrations/'))).toHaveLength(1);
});
it('refuses any other callback for the same client', async () => {
stubOutbound({ lookup: 'found' });
const res = await fetch(authorizeUrl(CLIENT_ID, 'https://chat.contoso.example/elsewhere'), { redirect: 'manual' });
expect(res.status).toBe(400);
});
it('refuses an unknown or disabled registration', async () => {
stubOutbound({ lookup: 'missing' });
const res = await fetch(authorizeUrl(CLIENT_ID, CALLBACK), { redirect: 'manual' });
expect(res.status).toBe(400);
expect(((await res.json()) as Record<string, unknown>).error).toBe('invalid_client');
});
it('fails closed with 503 when the registration cannot be checked', async () => {
stubOutbound({ lookup: 'error' });
const res = await fetch(authorizeUrl(CLIENT_ID, CALLBACK), { redirect: 'manual' });
expect(res.status).toBe(503);
});
it('caches the lookup, so a flow costs one backend call', async () => {
const outbound = stubOutbound({ lookup: 'found' });
await fetch(authorizeUrl(CLIENT_ID, CALLBACK), { redirect: 'manual' });
await fetch(authorizeUrl(CLIENT_ID, CALLBACK), { redirect: 'manual' });
expect(outbound.filter((o) => o.url.includes('/client-registrations/'))).toHaveLength(1);
});
it('keeps the vendor allowlist for dynamically registered clients', async () => {
stubOutbound({ lookup: 'found' });
const reg = await fetch(`${baseUrl}/oauth/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_name: 'x', redirect_uris: [CALLBACK] }),
});
expect(reg.status).toBe(400);
});
});
describe('/oauth/callback with a tenant client', () => {
it('returns the code to the registered callback', async () => {
stubOutbound({ lookup: 'found' });
const state = signState({ originalState: 's1', redirectUri: CALLBACK, clientId: CLIENT_ID });
const res = await fetch(`${baseUrl}/oauth/callback?code=c1&state=${encodeURIComponent(state)}`, { redirect: 'manual' });
expect(res.status).toBe(302);
const location = new URL(res.headers.get('location')!);
expect(`${location.origin}${location.pathname}`).toBe(CALLBACK);
expect(location.searchParams.get('code')).toBe('c1');
});
it('stops the flow when the registration was deleted after authorize', async () => {
stubOutbound({ lookup: 'missing' });
const state = signState({ originalState: 's1', redirectUri: CALLBACK, clientId: CLIENT_ID });
const res = await fetch(`${baseUrl}/oauth/callback?code=c1&state=${encodeURIComponent(state)}`, { redirect: 'manual' });
expect(res.status).toBe(400);
});
});
describe('/oauth/token with a tenant client', () => {
it('exchanges at the tenant authority and returns a token of that tenant', async () => {
const outbound = stubOutbound({ lookup: 'found' });
const r = await token({ grant_type: 'authorization_code', code: 'c1', code_verifier: 'v'.repeat(43), client_id: CLIENT_ID, redirect_uri: CALLBACK });
expect(r.status).toBe(200);
expect(typeof r.json.access_token).toBe('string');
const entra = outbound.find((o) => o.url.includes('/oauth2/v2.0/token'))!;
expect(new URL(entra.url).pathname).toBe(`/${TENANT}/oauth2/v2.0/token`);
});
it('discards a token of another tenant', async () => {
stubOutbound({ lookup: 'found', tokenTid: OTHER_TENANT });
const r = await token({ grant_type: 'refresh_token', refresh_token: 'rt', client_id: CLIENT_ID });
expect(r.status).toBe(400);
expect(r.json.error).toBe('invalid_grant');
expect(r.json.access_token).toBeUndefined();
expect(r.json.refresh_token).toBeUndefined();
});
it('refuses a deleted registration before calling Entra', async () => {
const outbound = stubOutbound({ lookup: 'missing' });
const r = await token({ grant_type: 'refresh_token', refresh_token: 'rt', client_id: CLIENT_ID });
expect(r.status).toBe(400);
expect(r.json.error).toBe('invalid_client');
expect(outbound.some((o) => o.url.includes('/oauth2/v2.0/token'))).toBe(false);
});
it('leaves other clients on the organizations authority', async () => {
const outbound = stubOutbound({ lookup: 'found' });
const r = await token({ grant_type: 'refresh_token', refresh_token: 'rt', client_id: 'some-dcr-client' });
expect(r.status).toBe(200);
const entra = outbound.find((o) => o.url.includes('/oauth2/v2.0/token'))!;
expect(new URL(entra.url).pathname).toBe('/organizations/oauth2/v2.0/token');
expect(outbound.some((o) => o.url.includes('/client-registrations/'))).toBe(false);
});
});
@@ -9,7 +9,7 @@
*/
import type { Request, Response, NextFunction } from 'express';
import crypto from 'node:crypto';
import { APPLICATION_KEY_PREFIX, extractTokenClaims, isApplicationKey, isTokenExpired, principalKeyOf } from './auth.js';
import { APPLICATION_KEY_PREFIX, describeTokenForLog, extractTokenClaims, isApplicationKey, isTokenExpired, principalKeyOf } from './auth.js';
import { runWithCaller, wantsPrettyJson } from './client.js';
import { API_BASE_URL, getPublicBaseUrl, parsePositiveInt } from './config.js';
import type { ApiErrorResponse, CheckMcpAccessResponse } from './generated/wire-types.generated.js';
@@ -394,7 +394,8 @@ function respondRateLimited(res: Response, kind: RateLimitKind, key: string, rpc
* map keyed `${kind}:${ip}` so the two surfaces cannot starve each other and
* the strict token budget stays independent of the general one. Applied
* per-route inside createOAuthRouter so any future /oauth endpoint must opt in
* consciously rather than inherit silence.
* consciously rather than inherit silence — oauth-rate-limit.test.ts fails any
* router route that has no limiter and is not a named exemption (D-281).
*/
function makeOAuthIpLimiter(kind: 'oauth' | 'oauth-token', limit: number) {
return (req: Request, res: Response, next: NextFunction): void => {
@@ -443,7 +444,7 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
// brokers that grant). Everything below keys on it: cache, rate limit, logs, caller context.
const upn = claims ? principalKeyOf(claims) : undefined;
if (!claims || !upn) {
console.error(`[mcp-auth] 401 invalid-token-claims (method=${rpcMethod})`);
console.error(`[mcp-auth] 401 invalid-token-claims (method=${rpcMethod}, ${describeTokenForLog(claims)})`);
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`);
res.status(401).json({ error: 'Invalid token: missing required claims' });
return;
@@ -479,7 +480,9 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
if (result.unauthenticated) {
// Spec: an invalid or expired token MUST get 401 with a challenge, so the client
// re-authenticates instead of reading a final "forbidden".
console.error(`[mcp-auth] 401 backend-rejected-token (method=${rpcMethod}, upn=${upn})`);
// The backend's reason never names the token; aud/azp/tid do. The common case is a token
// minted for another resource by a client that bypassed the OAuth proxy (own app, OBO).
console.error(`[mcp-auth] 401 backend-rejected-token (method=${rpcMethod}, upn=${upn}, ${describeTokenForLog(claims)})`);
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", error="invalid_token"`);
res.status(401).json({ error: 'Invalid token', reason: result.reason });
return;
@@ -90,3 +90,22 @@ export function isTokenExpired(claims: TokenClaims): boolean {
if (!claims.exp) return true;
return Date.now() / 1000 > claims.exp - 60;
}
/**
* The non-secret claims that explain a rejected token in one log line: the resource it was minted
* for (`aud`), the application that obtained it (`azp` on a v2.0 token, `appid` on v1.0) and the
* issuing tenant (`tid`). The backend's 401 never names the token, so an "audience validation
* failed" was undiagnosable from our side: a September 2026 support case needed a customer round
* trip to learn that a self-hosted client had minted the token for another resource. The values
* are unverified client input — control characters and whitespace are stripped, each is capped.
*/
export function describeTokenForLog(claims: TokenClaims | null | undefined): string {
const show = (value: unknown): string => {
if (value === undefined || value === null || value === '') return '-';
const raw = Array.isArray(value) ? value.join('|') : String(value);
// eslint-disable-next-line no-control-regex
const cleaned = raw.replace(/[\x00-\x1f\x7f\s]/g, '');
return cleaned.length > 120 ? `${cleaned.slice(0, 120)}...` : cleaned;
};
return `aud=${show(claims?.aud)} azp=${show(claims?.azp ?? claims?.appid)} tid=${show(claims?.tid)}`;
}
@@ -140,6 +140,10 @@ export interface AdminConfiguration {
diagnosticsDownloadTimeoutSeconds: number;
/** Controls who can access the remote MCP server (evaluated by McpUserService.IsAllowedAsync). "Disabled" = MCP off for everyone, platform roles included. "WhitelistOnly" = platform roles, delegated (MSP) admins and enabled McpUsers rows (default). "AllMembers" = the same grants plus every account holding an effective member role (Admin / Operator / Viewer) in the tenant its token was issued for — never "any authenticated token": an employee without a role, or a tenant that never onboarded, is denied. An explicitly disabled McpUsers row denies under every policy. */
mcpAccessPolicy: string;
/** Client-app binding of delegated tokens. A user token obtained by an application other than the platform's own registrations (a self-hosted AI client, an in-house tool) is always measured. When true it is also enforced: the token is accepted only while that application is an enabled member of the caller's tenant, and the caller then has at most Viewer rights, no platform role and at most DelegatedReader on managed tenants. Default false: measure only. */
enforceClientAppBinding: boolean;
/** Self-hosted MCP client registrations. When true, Tenant Admins can register the exact OAuth callback of a client their organization runs itself and connect it with client id amc_<id>; the MCP server binds that flow to the registering tenant. When false the portal section is hidden and every existing registration stops resolving (kill switch). Default false. */
mcpClientRegistrationEnabled: boolean;
/** NVD API key for higher rate limits (50 req/30s vs 5 req/30s without key). Free registration at https://nvd.nist.gov/developers/request-an-api-key null = operate without API key (slower, still functional). */
nvdApiKey: string;
/** JSON-serialized list of OpsAlertRule objects defining which event types trigger notifications. Provider-agnostic — rules apply to all enabled providers. */
@@ -764,6 +768,8 @@ export interface AuthMeResponse {
homedApp: string;
bootstrapTokenEnabled: boolean;
unrestrictedModeEnabled: boolean;
/** Whether Tenant Admins can register self-hosted MCP clients (platform switch); drives the settings section. */
mcpClientRegistrationEnabled: boolean;
/** When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit). */
whatsNewSeenPlatformUtc?: string;
/** Same for the Agent tab; null = never and the key is omitted. */
@@ -1152,6 +1158,19 @@ export interface CreateDelegationInvitationResponse {
expiresUtc: string;
}
/** Body of POST tenants/{tenantId}/mcp-client-registrations. */
export interface CreateMcpClientRegistrationRequest {
/** Display name, 1-64 characters. */
name: string;
/** The client's exact OAuth callback URL: https, or http on localhost / 127.0.0.1; no query, fragment or wildcard. */
redirectUri: string;
}
/** Response of POST tenants/{tenantId}/mcp-client-registrations: the stored registration. */
export interface CreateMcpClientRegistrationResponse {
registration: McpClientRegistrationItem;
}
/** Body of POST global/tenant-groups. */
export interface CreateTenantGroupRequest {
name: string;
@@ -2810,6 +2829,36 @@ export interface ManagedTenantListResponse {
tenants: ManagedTenantItem[];
}
/** One self-hosted MCP client registration as the portal lists it. */
export interface McpClientRegistrationItem {
registrationId: string;
/** The OAuth client id the self-hosted client is configured with: amc_ + registration id. */
clientId: string;
name: string;
redirectUri: string;
createdBy: string;
createdUtc: string;
}
/** Response of GET tenants/{tenantId}/mcp-client-registrations. */
export interface McpClientRegistrationListResponse {
/** Whether the platform currently accepts self-hosted client registrations (operator switch). */
enabled: boolean;
/** How many registrations a tenant may hold. */
maxRegistrations: number;
/** The MCP server URL the self-hosted client connects to. */
serverUrl: string;
registrations: McpClientRegistrationItem[];
}
/** Response of the anonymous GET mcp/client-registrations/{registrationId} — the MCP server's OAuth proxy resolves a client id amc_<registrationId> through it. Carries nothing secret: the tenant id and the callback the registration binds the flow to. */
export interface McpClientRegistrationLookupResponse {
registrationId: string;
tenantId: string;
redirectUri: string;
name: string;
}
/** Response of GET health/mcp: the standalone MCP-server reachability probe. */
export interface McpHealthCheckResponse {
timestamp: string;
@@ -3321,7 +3370,7 @@ export interface RegisterSessionResponse {
registeredAt: string;
/** Non-null when the session was already marked as terminal by an admin before agent restart. Values: "Succeeded", "Failed". Agent should run cleanup instead of starting monitoring. */
adminAction?: string;
/** Authoritative signal: which validator accepted this device. Lets the agent reconcile its registry-based enrollment-type detection against the backend's verdict (e.g. AutopilotV1 → Classic flow, DeviceAssociation → DevPrep flow). Older backends that do not set this return Unknown — agent falls back to its own detection. */
/** Which validator accepted this device. The agent only logs it and forwards it on the SessionStarted signal; its enrollment flow comes from its own registry detection. */
validatedBy: ValidatorType;
/** Machine-readable reason on a failure response (see AgentErrorCodes). Null on success and on failures that carry no agent-actionable code. */
errorCode?: string;
@@ -4171,7 +4220,7 @@ export interface SessionSummary {
completionSource: string;
/** Non-null only when an administrator explicitly flipped the session via the portal (MarkSessionSucceeded / MarkSessionFailed). Values: null (default, agent-driven), "Succeeded", "Failed". This is the authoritative source for the backend's AdminAction response field sent to agents. Previously the backend inferred admin-override from "status is terminal + current event is not a completion marker", which fired falsely on every post-completion event the agent sent (agent_shutting_down, diagnostics_uploaded, enrollment_summary_shown). The dedicated field eliminates that false-positive. */
adminMarkedAction?: string;
/** Which backend device-validation path accepted this device at session registration — ValidatorType name as string: "AutopilotV1" (Autopilot S/N lookup), "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc", or "Bootstrap" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off. */
/** Which backend device-validation path accepted this device at session registration — ValidatorType name as string: "AutopilotV1" (Autopilot S/N lookup), "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc", "IntuneEnrollment" (enrolled Intune device, no pre-registration) or "Bootstrap" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off. */
validatedBy: string;
eventCount: number;
durationSeconds?: number;
@@ -4694,6 +4743,8 @@ export interface TenantConfiguration {
mcpDisabled: boolean;
/** Optional reason why MCP access was disabled. Shown to the caller in the 403 response. */
mcpDisabledReason?: string | null;
/** How many self-hosted MCP client registrations this tenant may hold. Null = the platform default (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction GA gate, TenantConfigPatchService.GaOnlyFields). */
mcpClientRegistrationLimit?: number | null;
/** Optional per-tenant override for the device (agent/cert) API rate limit. If null, the effective limit is the global AdminConfiguration.GlobalRateLimitRequestsPerMinute. If set, this value takes precedence. Global-Admin-only (see UpdateTenantConfigurationFunction GA-gate). */
customRateLimitRequestsPerMinute?: number | null;
/** Optional per-tenant override for the user (portal/JWT) API rate limit applied to standard users (Tenant Admins, Operators, Viewers). If null, the effective limit is the global AdminConfiguration.UserRateLimitRequestsPerMinute. Global-Admin-only. Note: Global Admins are rate-limited by the global GlobalAdminRateLimitRequestsPerMinute (cross-tenant), so this override does not apply to them. */
@@ -4724,15 +4775,15 @@ export interface TenantConfiguration {
modelWhitelist: string;
/** Whether to validate devices against Intune Autopilot device registration Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All) */
validateAutopilotDevice: boolean;
/** Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number via importedDeviceIdentities/searchExistingIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.ReadWrite.All) */
/** Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number, read from importedDeviceIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All) */
validateCorporateIdentifier: boolean;
/** Whether to validate devices against the Windows Autopilot device preparation "Device association" catalog via Graph (tenantAssociatedDevices, serial number match). One of the accepting methods of the device-validation gate, evaluated after the Autopilot and Corporate Identifier lookups (see SecurityValidator). Device association is GA since 2026-08-25; associated devices are marked corporate-owned by Intune itself, so no corporate identifier exists for them. Requires the same Graph permission as the other validators (DeviceManagementServiceConfig.Read.All). */
validateDeviceAssociation: boolean;
/** Whether to validate Windows 365 Cloud PCs as a fallback when the Autopilot / Corporate Identifier lookups miss. Cloud PCs are provisioned by the Windows 365 service and are structurally never Autopilot-registered; this validator instead resolves the Intune device id from the (chain-validated) MDM client certificate's Subject CN and requires a matching cloudPC object (virtualEndpoint/cloudPCs, managedDeviceId eq CN) in the tenant. Only service-provisioned Cloud PCs have such an object, so no other enrolled device can pass this stage. Requires the optional Graph permission CloudPC.Read.All (feature "W365CloudPcValidation" in the grant script). */
validateCloudPcDevice: boolean;
/** Cert-to-device binding check (Global-Admin-only preview, SHADOW mode). Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory, proving the certificate belongs to a device the tenant actually enrolled. The result is recorded as telemetry only and never blocks enrollment, because a device object can in principle appear later than the agent's first call - measuring that race is the point of the shadow pass. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature "IntuneDeviceBinding" in the grant script). */
/** Intune Enrollment Validation: the last accepting method of the device-validation gate. Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory and admits the device when it is an enrolled device of the tenant, without any pre-registration (Autopilot hash, corporate identifier, device association). Evaluated only when no earlier validator admitted the device. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature "IntuneDeviceBinding" in the grant script). */
validateIntuneDeviceBinding: boolean;
/** Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when ValidateAutopilotDevice is false. Default: false */
/** Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when no device validation is enabled (see HasAnyDeviceValidation). Default: false */
allowInsecureAgentRequests: boolean;
/** Data retention period in days Sessions and events older than this will be deleted by the daily maintenance job Default: 90 days */
dataRetentionDays: number;
@@ -4902,8 +4953,8 @@ export interface TenantFeatureFlagsResponse {
bootstrapTokenEnabled: boolean;
/** Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail. */
diagnosticsUploadConfigured: boolean;
/** Drives the "Autopilot Device Validation disabled" dashboard banner. */
validateAutopilotDevice: boolean;
/** At least one device-validation method is enabled (HasAnyDeviceValidation); false drives the "agent ingestion is blocked" dashboard banner. */
deviceValidationEnabled: boolean;
/** Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids. */
appHomingFunnelActive: boolean;
showScriptOutput: boolean;
@@ -5353,8 +5404,8 @@ export interface UserUsageRecord {
lastRequestAt: string;
}
/** Identifies which validator authorized the device during session registration. Surfaced in the RegisterSession response so the agent can reconcile against its own registry-based detection and, when needed, switch its enrollment flow. */
export type ValidatorType = "Unknown" | "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap" | "CloudPc";
/** Identifies which validator authorized the device during session registration. Stored on the session row and echoed in the RegisterSession response. The agent parses that response strictly (an unknown name fails the registration), so a new member ships with an agent release BEFORE the backend that can send it; agents self-update at every start, so a released agent is what every new registration runs. */
export type ValidatorType = "Unknown" | "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap" | "CloudPc" | "IntuneEnrollment";
/** One ACTIVE verdict-calibration alert episode (operator-only). Persisted as the verdictcalibration|{kind}|{path}|{status} keyspace of the notification tracker — the row IS the dedup (one ops event per episode) and the alerts[] payload of the calibration endpoint. Deleted when the signal re-arms (share back under 1.5× baseline, path stops occurring, evidence-gap share back under 15 %) or by the tracker's 30-day retention sweep. Numbers refresh on every radar pass; FirstNotifiedAt never moves. Dimension concentration is CORRELATION only — every consumer says so. */
export interface VerdictCalibrationAlert {
@@ -218,6 +218,7 @@ export const API_ERROR_CODES = [
"InsufficientPermissions",
"CrossTenantAccessDenied",
"TenantSuspended",
"ClientAppNotRegistered",
"QuotaExceeded",
"PayloadTooLarge",
"TelemetryItemsRejected",
+182 -29
View File
@@ -23,7 +23,9 @@
import { Router } from 'express';
import crypto from 'node:crypto';
import { oauthRateLimit, oauthTokenRateLimit } from './access-guard.js';
import { ENTRA_LOGIN_BASE_URL, getPublicBaseUrl } from './config.js';
import { extractTokenClaims } from './auth.js';
import { API_BASE_URL, ENTRA_LOGIN_BASE_URL, getPublicBaseUrl } from './config.js';
import type { McpClientRegistrationLookupResponse } from './generated/wire-types.generated.js';
import { ClientMetadataError, isClientIdMetadataUrl, resolveClientMetadata } from './cimd.js';
import { MAX_CLIENT_NAME_LENGTH, MAX_REDIRECT_URIS_PER_CLIENT, MAX_REDIRECT_URI_LENGTH } from './oauth-limits.js';
@@ -216,6 +218,17 @@ export function sanitizeForLog(value: unknown, maxLength = 200): string {
return cleaned.length > maxLength ? `${cleaned.slice(0, maxLength)}...` : cleaned;
}
/** Origin + pathname of a redirect_uri for a log line; the query (state, codes) is never logged. */
function redirectTargetForLog(uri: unknown): string {
if (typeof uri !== 'string') return `<${typeof uri}>`;
try {
const parsed = new URL(uri);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return '<unparseable>';
}
}
/** OAuth error codes (RFC 6749 §5.2, OIDC Core §3.1.2.6) a client acts on; anything else is relayed as invalid_request. */
const OAUTH_ERROR_CODES = new Set([
'invalid_request', 'invalid_client', 'invalid_grant', 'unauthorized_client', 'unsupported_grant_type',
@@ -507,7 +520,21 @@ export function redirectUriMatches(registered: string[], requested: string): boo
async function resolveRegisteredClient(
clientId: string,
where: string,
): Promise<{ ok: true; redirectUris: string[]; name: string } | { ok: false; error: string }> {
): Promise<{ ok: true; redirectUris: string[]; name: string; tenantId?: string } | { ok: false; error: string }> {
const registrationId = tenantClientRegistrationId(clientId);
if (registrationId) {
const client = await lookupTenantClient(registrationId);
if (client === 'unavailable') {
console.error(`[oauth/${where}] tenant client ${registrationId}: registration lookup unavailable`);
return { ok: false, error: 'temporarily_unavailable' };
}
if (!client) {
console.error(`[oauth/${where}] tenant client ${registrationId}: unknown, disabled or feature off`);
return { ok: false, error: 'invalid_client' };
}
console.error(`[oauth/${where}] tenant client ${registrationId} (tid=${client.tenantId}, ${sanitizeForLog(client.name)})`);
return { ok: true, redirectUris: [client.redirectUri], name: client.name, tenantId: client.tenantId };
}
if (isClientIdMetadataUrl(clientId)) {
try {
const md = await resolveClientMetadata(clientId);
@@ -530,6 +557,86 @@ async function resolveRegisteredClient(
return { ok: true, redirectUris: registered.redirectUris, name: registered.name };
}
// ---- Tenant-bound clients (portal registrations) ---------------------------
//
// A Tenant Admin registers the exact callback of a self-hosted client (LibreChat and the like) in the
// portal and receives client_id `amc_<registrationId>`. The registration is that client's allowlist
// entry — the global vendor allowlist keeps governing dynamic registration — and it binds the flow to
// the registering tenant: authorize and token go to that tenant's Entra authority, and the token
// endpoint discards a token whose tid is not the registration's. Registrations live in the backend;
// this server reads one through an anonymous lookup by its unguessable id (no user token exists
// before the exchange) and caches the answer briefly, so a deletion or the operator switch takes
// effect within TENANT_CLIENT_CACHE_MS.
const TENANT_CLIENT_ID = /^amc_([0-9a-f]{32})$/;
const TENANT_CLIENT_CACHE_MS = 60_000;
const TENANT_CLIENT_CACHE_MAX = 500;
const TENANT_CLIENT_LOOKUP_TIMEOUT_MS = 5_000;
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
interface TenantClient {
tenantId: string;
redirectUri: string;
name: string;
}
const tenantClientCache = new Map<string, { value: TenantClient | null; expiresAt: number }>();
/** The registration id of a tenant-bound client_id, or null for every other client identity. */
export function tenantClientRegistrationId(clientId: string | null | undefined): string | null {
const match = typeof clientId === 'string' ? TENANT_CLIENT_ID.exec(clientId) : null;
return match ? match[1] : null;
}
/** Test seam: forget cached registrations. */
export function clearTenantClientCache(): void {
tenantClientCache.clear();
}
/**
* The registration behind a tenant-bound client_id: the client, null when the backend answers 404
* (unknown, disabled, or the feature switched off — cached like a hit), or 'unavailable' when the
* backend could not be asked (never cached, the caller fails closed).
*/
async function lookupTenantClient(registrationId: string): Promise<TenantClient | null | 'unavailable'> {
const now = Date.now();
const cached = tenantClientCache.get(registrationId);
if (cached && cached.expiresAt > now) return cached.value;
let res: Response;
try {
res = await fetch(`${API_BASE_URL}/api/auth/mcp/client-registrations/${encodeURIComponent(registrationId)}`, {
signal: AbortSignal.timeout(TENANT_CLIENT_LOOKUP_TIMEOUT_MS),
});
} catch {
return 'unavailable';
}
let value: TenantClient | null = null;
if (res.ok) {
const body = (await res.json().catch(() => null)) as McpClientRegistrationLookupResponse | null;
const tenantId = typeof body?.tenantId === 'string' ? body.tenantId.toLowerCase() : '';
if (!body || !GUID.test(tenantId) || typeof body.redirectUri !== 'string' || body.registrationId !== registrationId) {
return 'unavailable';
}
value = { tenantId, redirectUri: body.redirectUri, name: body.name || 'Self-hosted client' };
} else if (res.status !== 404) {
return 'unavailable';
}
if (tenantClientCache.size >= TENANT_CLIENT_CACHE_MAX) {
const oldest = tenantClientCache.keys().next().value;
if (oldest !== undefined) tenantClientCache.delete(oldest);
}
tenantClientCache.set(registrationId, { value, expiresAt: now + TENANT_CLIENT_CACHE_MS });
return value;
}
/** The registering tenant's authority on the configured Entra host (sovereign clouds included). */
function tenantAuthority(tenantId: string): string {
return AUTHORITY.replace(/\/[^/]+$/, `/${tenantId}`);
}
export function createOAuthRouter(): Router {
const router = Router();
@@ -570,6 +677,10 @@ export function createOAuthRouter(): Router {
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
// Every client of this proxy is public (PKCE); it issues no client secrets. Without this
// field RFC 8414 implies client_secret_basic, and clients that follow it (LibreChat) register
// with a secret method and then fail the token exchange for lack of a secret.
token_endpoint_auth_methods_supported: ['none'],
// MCP spec 2026-07-28 client registration: clients that support Client ID
// Metadata Documents pick them over dynamic registration when this flag
// is present (cimd.ts resolves the document at /oauth/authorize).
@@ -591,7 +702,7 @@ export function createOAuthRouter(): Router {
// registration itself, an org-wide auth DoS. The .well-known discovery docs
// stay unthrottled (static JSON, no outbound work, no logging).
router.post('/oauth/register', oauthRateLimit, (req, res) => {
const { client_name, redirect_uris, grant_types, response_types, token_endpoint_auth_method } = req.body ?? {};
const { client_name, redirect_uris, grant_types, response_types } = req.body ?? {};
// Field-level bounds — backstop the route-level body-size limit. The
// parser caps total body bytes; these caps stop a single registration
@@ -630,16 +741,19 @@ export function createOAuthRouter(): Router {
// an attacker from registering arbitrary post-callback destinations.
const invalidUris = uris.filter((u: unknown) => typeof u !== 'string' || !isAllowedRedirectUri(u));
if (invalidUris.length > 0) {
// Name the rejected targets: a self-hosted client (LibreChat, an in-house agent) registers its
// own domain, and the operator needs the host to decide on an MCP_ALLOWED_REDIRECT_HOSTS entry.
const rejected = invalidUris.slice(0, 3).map((u) => sanitizeForLog(redirectTargetForLog(u), 160)).join(', ');
console.error(
`[oauth/register] Rejected client ${sanitizeForLog(client_name ?? 'unknown')}: ` +
`${invalidUris.length} redirect_uri(s) outside allowlist`,
`${invalidUris.length} redirect_uri(s) outside allowlist (${rejected})`,
);
res.status(400).json({
error: 'invalid_redirect_uri',
error_description:
'One or more redirect_uris are not in the allowlist. Loopback (localhost / 127.0.0.1) ' +
'is always accepted; hosted AI client hosts are governed by the ' +
'MCP_ALLOWED_REDIRECT_HOSTS env var (default: Anthropic, OpenAI, Google).',
'MCP_ALLOWED_REDIRECT_HOSTS env var (default: Anthropic, OpenAI, VS Code).',
});
return;
}
@@ -657,7 +771,10 @@ export function createOAuthRouter(): Router {
redirect_uris: uris,
grant_types: grant_types ?? ['authorization_code', 'refresh_token'],
response_types: response_types ?? ['code'],
token_endpoint_auth_method: token_endpoint_auth_method ?? 'none',
// Always 'none', whatever was requested: no secret is ever issued (RFC 7591 §3.2.1 lets the
// server replace a requested value). Echoing a secret-based method made SDK clients demand a
// client_secret at the token endpoint that does not exist.
token_endpoint_auth_method: 'none',
});
});
@@ -706,14 +823,6 @@ export function createOAuthRouter(): Router {
res.status(400).json({ error: 'invalid_request', error_description: 'redirect_uri is required' });
return;
}
if (!isAllowedRedirectUri(redirect_uri)) {
console.error(`[oauth/authorize] Rejected redirect_uri (not in allowlist): ${sanitizeForLog(redirect_uri)}`);
res.status(400).json({
error: 'invalid_request',
error_description: 'redirect_uri host is not in the allowlist',
});
return;
}
// client_id is REQUIRED. Now that it is a stateless, replica-portable
// signed token, there is no cold-start reason to tolerate its absence —
// and tolerating it would let a caller bypass the per-client redirect_uri
@@ -733,13 +842,26 @@ export function createOAuthRouter(): Router {
// No registry lookup, correct across cold starts and replicas. A forged /
// unfetchable / malformed client fails closed; a valid one must still carry
// the exact redirect_uri.
// Every client except a tenant registration is held to the global host allowlist — checked before
// the client is resolved, so a hostile redirect never triggers a metadata-document fetch. A tenant
// registration IS the allowlist entry for its exact callback, matched below.
if (tenantClientRegistrationId(client_id) === null && !isAllowedRedirectUri(redirect_uri)) {
console.error(`[oauth/authorize] Rejected redirect_uri (not in allowlist): ${sanitizeForLog(redirect_uri)}`);
res.status(400).json({
error: 'invalid_request',
error_description: 'redirect_uri host is not in the allowlist',
});
return;
}
const registered = await resolveRegisteredClient(client_id, 'authorize');
if (!registered.ok) {
res.status(400).json({
res.status(registered.error === 'temporarily_unavailable' ? 503 : 400).json({
error: registered.error,
error_description: registered.error === 'invalid_client'
? 'client_id is invalid — use a Client ID Metadata Document URL or register via /oauth/register first'
: 'client_id metadata document is malformed',
: registered.error === 'temporarily_unavailable'
? 'client registration could not be checked — retry shortly'
: 'client_id metadata document is malformed',
});
return;
}
@@ -787,7 +909,10 @@ export function createOAuthRouter(): Router {
prompt: 'select_account',
});
res.redirect(`${AUTHORITY}/oauth2/v2.0/authorize?${entraParams}`);
// A tenant-bound client signs in at its tenant's authority: an account of another organization
// cannot complete the sign-in at all (guests are the tenant's own decision).
const authority = registered.tenantId ? tenantAuthority(registered.tenantId) : AUTHORITY;
res.redirect(`${authority}/oauth2/v2.0/authorize?${entraParams}`);
});
// --- Callback: receive code from Entra ID, forward to Claude Code ---
@@ -823,10 +948,15 @@ export function createOAuthRouter(): Router {
const redirectUri = decoded.redirectUri;
const clientId = decoded.clientId;
// Re-validate the redirectUri at callback time too. Belt-and-suspenders:
// /oauth/authorize already gated the URI, but the state value transits the
// user-agent — re-running the host allowlist here closes any replay gap.
if (!isAllowedRedirectUri(redirectUri)) {
// Stateless re-check of the client (signed token, metadata document or tenant registration — the
// latter two normally cache hits within the 10-min state window). The redirectUri is already pinned
// inside the HMAC-verified state, so this is defense-in-depth: a valid client must still list the
// redirectUri, and a registration deleted since authorize stops the flow here.
const registered = clientId ? await resolveRegisteredClient(clientId, 'callback') : null;
// Re-validate the redirectUri at callback time too: the state value transits the user-agent —
// re-running the host allowlist closes any replay gap. A tenant registration is its own allowlist.
const tenantBound = registered?.ok === true && !!registered.tenantId;
if (!tenantBound && !isAllowedRedirectUri(redirectUri)) {
console.error(`[oauth/callback] Rejected redirectUri from state (not in allowlist): ${sanitizeForLog(redirectUri)}`);
res.status(400).json({
error: 'invalid_request',
@@ -834,13 +964,7 @@ export function createOAuthRouter(): Router {
});
return;
}
if (clientId) {
// Stateless re-check (signed token or metadata document, the latter
// normally a cache hit within the 10-min state window) — correct across
// cold starts/replicas. The redirectUri is already pinned inside the
// HMAC-verified state, so this only adds defense-in-depth: a valid
// client must still list the redirectUri. A forged client_id fails closed.
const registered = await resolveRegisteredClient(clientId, 'callback');
if (registered) {
if (!registered.ok || !redirectUriMatches(registered.redirectUris, redirectUri)) {
console.error(`[oauth/callback] client_id invalid or redirectUri not registered: ${sanitizeForLog(redirectUri)}`);
res.status(400).json({
@@ -914,6 +1038,24 @@ export function createOAuthRouter(): Router {
return;
}
// A tenant-bound client exchanges at its tenant's authority, and only while its registration exists.
const registrationId = tenantClientRegistrationId(params.client_id);
let boundTenantId: string | null = null;
if (registrationId) {
const client = await lookupTenantClient(registrationId);
if (client === 'unavailable') {
res.status(503).json({ error: 'temporarily_unavailable', error_description: 'client registration could not be checked — retry shortly' });
return;
}
if (!client) {
console.error(`[oauth/token] tenant client ${registrationId}: unknown, disabled or feature off`);
res.status(400).json({ error: 'invalid_client', error_description: 'client registration is unknown or disabled' });
return;
}
boundTenantId = client.tenantId;
}
const authority = boundTenantId ? tenantAuthority(boundTenantId) : AUTHORITY;
// Build form body for Entra ID token endpoint
const body = new URLSearchParams();
@@ -929,7 +1071,7 @@ export function createOAuthRouter(): Router {
body.set('scope', SCOPES);
try {
const tokenResponse = await fetch(`${AUTHORITY}/oauth2/v2.0/token`, {
const tokenResponse = await fetch(`${authority}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
@@ -953,6 +1095,17 @@ export function createOAuthRouter(): Router {
`correlation_id=${sanitizeForLog(err.correlation_id)}`,
);
}
if (tokenResponse.status === 200 && boundTenantId) {
// The authority already confines the sign-in; this is the second half of the binding. The token
// comes straight from Entra over TLS, so its claims can be read without a signature check here.
const accessToken = typeof data?.access_token === 'string' ? data.access_token : '';
const tid = extractTokenClaims(accessToken)?.tid?.toLowerCase();
if (tid !== boundTenantId) {
console.error(`[oauth/token] tenant client ${registrationId}: token tid=${sanitizeForLog(tid ?? '(none)')} is not the registration's tenant — discarded`);
res.status(400).json({ error: 'invalid_grant', error_description: 'the token was issued for another organization than this client registration' });
return;
}
}
res.status(tokenResponse.status).json(tokenResponse.status === 200 ? data : sanitizeTokenErrorBody(data));
} catch (err) {
// AbortSignal.timeout() rejects with a DOMException named 'TimeoutError'
@@ -307,6 +307,11 @@ namespace AutopilotMonitor.Shared
public const string InsufficientPermissions = "InsufficientPermissions";
public const string CrossTenantAccessDenied = "CrossTenantAccessDenied";
public const string TenantSuspended = "TenantSuspended";
/// <summary>
/// 403 while client-app binding is enforced: a delegated token obtained by an application that
/// is neither one of the platform's own registrations nor an enabled member of the caller's tenant.
/// </summary>
public const string ClientAppNotRegistered = "ClientAppNotRegistered";
// ── MCP quota (McpQuotaEnforcementMiddleware) ──
public const string QuotaExceeded = "QuotaExceeded";
@@ -1337,6 +1342,11 @@ namespace AutopilotMonitor.Shared
// partition stays small enough that a second key layout would cost more than it saves.
public const string RuleSubmissions = "RuleSubmissions";
// Self-hosted MCP client registrations (Tenant Admin, portal). PK = "registrations" for every
// tenant, RK = registration id (32 hex); the MCP proxy's anonymous lookup is a point read, the
// tenant list a TenantId property filter (at most three rows per tenant).
public const string McpClientRegistrations = "McpClientRegistrations";
// Bootstrap sessions (OOBE pre-enrollment agent deployment)
public const string BootstrapSessions = "BootstrapSessions";
@@ -1516,6 +1526,7 @@ namespace AutopilotMonitor.Shared
BlockedVersions,
SessionReports,
RuleSubmissions,
McpClientRegistrations,
BootstrapSessions,
GlobalNotifications,
TenantNotifications,
@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// Self-hosted MCP client registrations: a Tenant Admin registers the exact OAuth callback of a client
/// the organization runs itself (a self-hosted chat front end) and hands it client id
/// <c>amc_&lt;RegistrationId&gt;</c>. The MCP server's OAuth proxy treats the registration as that
/// client's redirect allowlist entry and binds the flow to <see cref="McpClientRegistration.TenantId"/>.
/// One partition for every tenant (a registration is a rare, deliberate act), RK = registration id, so
/// the MCP server's anonymous lookup is a point read.
/// </summary>
public interface IMcpClientRegistrationRepository
{
/// <summary>Stores a new registration; false when storage refused it.</summary>
Task<bool> CreateAsync(McpClientRegistration registration);
/// <summary>One registration by id, or null.</summary>
Task<McpClientRegistration?> GetAsync(string registrationId);
/// <summary>Every registration of one tenant.</summary>
Task<List<McpClientRegistration>> GetForTenantAsync(string tenantId);
/// <summary>Deletes a registration; false when it did not exist.</summary>
Task<bool> DeleteAsync(string registrationId);
}
/// <summary>One self-hosted MCP client registration.</summary>
public class McpClientRegistration
{
/// <summary>32 lowercase hex characters; the client id is <c>amc_</c> + this value.</summary>
public string RegistrationId { get; set; } = string.Empty;
/// <summary>The registering tenant (lowercase GUID) — the only tenant the client can sign in to.</summary>
public string TenantId { get; set; } = string.Empty;
/// <summary>Display name the Tenant Admin chose.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>The exact callback URL (https, or http on loopback).</summary>
public string RedirectUri { get; set; } = string.Empty;
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
}
@@ -223,6 +223,23 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string McpAccessPolicy { get; set; } = nameof(Models.McpAccessPolicy.WhitelistOnly);
/// <summary>
/// Client-app binding of delegated tokens. A user token obtained by an application other than the
/// platform's own registrations (a self-hosted AI client, an in-house tool) is always measured.
/// When true it is also enforced: the token is accepted only while that application is an enabled
/// member of the caller's tenant, and the caller then has at most Viewer rights, no platform role
/// and at most DelegatedReader on managed tenants. Default false: measure only.
/// </summary>
public bool EnforceClientAppBinding { get; set; } = false;
/// <summary>
/// Self-hosted MCP client registrations. When true, Tenant Admins can register the exact OAuth
/// callback of a client their organization runs itself and connect it with client id amc_&lt;id&gt;;
/// the MCP server binds that flow to the registering tenant. When false the portal section is
/// hidden and every existing registration stops resolving (kill switch). Default false.
/// </summary>
public bool McpClientRegistrationEnabled { get; set; } = false;
// ===== VULNERABILITY CORRELATION SETTINGS =====
/// <summary>
@@ -155,6 +155,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string? McpDisabledReason { get; set; }
/// <summary>
/// How many self-hosted MCP client registrations this tenant may hold. Null = the platform default
/// (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction
/// GA gate, TenantConfigPatchService.GaOnlyFields).
/// </summary>
public int? McpClientRegistrationLimit { get; set; } = null;
// ===== SECURITY SETTINGS =====
/// <summary>
@@ -278,8 +285,8 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Whether to validate devices against Intune Corporate Device Identifiers
/// (manufacturer + model + serial number via importedDeviceIdentities/searchExistingIdentities).
/// Requires Graph API integration (admin consent for DeviceManagementServiceConfig.ReadWrite.All)
/// (manufacturer + model + serial number, read from importedDeviceIdentities).
/// Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All)
/// </summary>
public bool ValidateCorporateIdentifier { get; set; } = false;
@@ -307,20 +314,20 @@ namespace AutopilotMonitor.Shared.Models
public bool ValidateCloudPcDevice { get; set; } = false;
/// <summary>
/// Cert-to-device binding check (Global-Admin-only preview, SHADOW mode).
/// Intune Enrollment Validation: the last accepting method of the device-validation gate.
/// Resolves the Intune managedDevice id carried in the agent client certificate's Subject
/// CN against this tenant's own managedDevices inventory, proving the certificate belongs
/// to a device the tenant actually enrolled. The result is recorded as telemetry only and
/// never blocks enrollment, because a device object can in principle appear later than the
/// agent's first call - measuring that race is the point of the shadow pass. Requires the
/// optional Graph permission DeviceManagementManagedDevices.Read.All (feature
/// "IntuneDeviceBinding" in the grant script).
/// CN against this tenant's own managedDevices inventory and admits the device when it is
/// an enrolled device of the tenant, without any pre-registration (Autopilot hash,
/// corporate identifier, device association). Evaluated only when no earlier validator
/// admitted the device. Requires the optional Graph permission
/// DeviceManagementManagedDevices.Read.All (feature "IntuneDeviceBinding" in the grant script).
/// </summary>
public bool ValidateIntuneDeviceBinding { get; set; } = false;
/// <summary>
/// Emergency bypass for agent security gate (Global Admin use only).
/// If true, agent requests are accepted even when ValidateAutopilotDevice is false.
/// If true, agent requests are accepted even when no device validation is enabled
/// (see <see cref="HasAnyDeviceValidation"/>).
/// Default: false
/// </summary>
public bool AllowInsecureAgentRequests { get; set; } = false;
@@ -1023,6 +1030,18 @@ namespace AutopilotMonitor.Shared.Models
return channels;
}
/// <summary>
/// True when at least one device-validation method is enabled. The single definition of
/// the agent gate's precondition: without one (and without AllowInsecureAgentRequests)
/// the agent ingestion endpoints reject every request.
/// </summary>
public bool HasAnyDeviceValidation()
=> ValidateAutopilotDevice
|| ValidateCorporateIdentifier
|| ValidateDeviceAssociation
|| ValidateCloudPcDevice
|| ValidateIntuneDeviceBinding;
/// <summary>
/// Checks if the tenant is currently disabled
/// Takes into account DisabledUntil if set
@@ -1084,6 +1103,7 @@ namespace AutopilotMonitor.Shared.Models
DisabledUntil = null,
McpDisabled = false,
McpDisabledReason = null,
McpClientRegistrationLimit = null,
CustomRateLimitRequestsPerMinute = null,
CustomUserRateLimitRequestsPerMinute = null,
ManufacturerWhitelist = "Dell*,HP*,Lenovo*,Microsoft Corporation",
@@ -16,8 +16,8 @@ namespace AutopilotMonitor.Shared.Models
public bool BootstrapTokenEnabled { get; set; }
/// <summary>Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail.</summary>
public bool DiagnosticsUploadConfigured { get; set; }
/// <summary>Drives the "Autopilot Device Validation disabled" dashboard banner.</summary>
public bool ValidateAutopilotDevice { get; set; }
/// <summary>At least one device-validation method is enabled (<see cref="TenantConfiguration.HasAnyDeviceValidation"/>); false drives the "agent ingestion is blocked" dashboard banner.</summary>
public bool DeviceValidationEnabled { get; set; }
/// <summary>Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids.</summary>
public bool AppHomingFunnelActive { get; set; }
public bool ShowScriptOutput { get; set; }
@@ -28,9 +28,9 @@
/// <summary>
/// Read Intune managed device objects (<c>deviceManagement/managedDevices</c>). Optional
/// add-on permission backing the Intune device-binding check, which verifies that the
/// managedDevice id carried in the agent's client certificate actually exists in the
/// tenant it claims. Not part of the default-consent set — must be granted per tenant.
/// add-on permission backing Intune Enrollment Validation, which resolves the managedDevice
/// id carried in the agent's client certificate in the tenant it claims. Not part of the
/// default-consent set — must be granted per tenant.
/// </summary>
public const string DeviceManagementManagedDevicesReadAll = "DeviceManagementManagedDevices.Read.All";
@@ -18,8 +18,9 @@ namespace AutopilotMonitor.Shared.Models.Graph
public const string FeatureW365CloudPcValidation = "W365CloudPcValidation";
/// <summary>
/// Verifies that the Intune managedDevice id carried in the agent's client certificate
/// actually exists in the tenant the request claims (cert-to-device binding).
/// Intune Enrollment Validation: resolves the Intune managedDevice id carried in the
/// agent's client certificate against the tenant's managed devices, which admits devices
/// without any pre-registration and observes the ones other validators admitted.
/// </summary>
public const string FeatureIntuneDeviceBinding = "IntuneDeviceBinding";
@@ -38,6 +38,9 @@ namespace AutopilotMonitor.Shared.Models
public bool BootstrapTokenEnabled { get; set; }
public bool UnrestrictedModeEnabled { get; set; }
/// <summary>Whether Tenant Admins can register self-hosted MCP clients (platform switch); drives the settings section.</summary>
public bool McpClientRegistrationEnabled { get; set; }
/// <summary>When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit).</summary>
public DateTime? WhatsNewSeenPlatformUtc { get; set; }
/// <summary>Same for the Agent tab; null = never and the key is omitted.</summary>
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>One self-hosted MCP client registration as the portal lists it.</summary>
// Declaration order == wire order.
public class McpClientRegistrationItem
{
public string RegistrationId { get; set; } = default!;
/// <summary>The OAuth client id the self-hosted client is configured with: <c>amc_</c> + registration id.</summary>
public string ClientId { get; set; } = default!;
public string Name { get; set; } = default!;
public string RedirectUri { get; set; } = default!;
public string CreatedBy { get; set; } = default!;
public DateTime CreatedUtc { get; set; }
}
/// <summary>Response of GET tenants/{tenantId}/mcp-client-registrations.</summary>
// Declaration order == wire order.
public class McpClientRegistrationListResponse : IApiResponse
{
/// <summary>Whether the platform currently accepts self-hosted client registrations (operator switch).</summary>
public bool Enabled { get; set; }
/// <summary>How many registrations a tenant may hold.</summary>
public int MaxRegistrations { get; set; }
/// <summary>The MCP server URL the self-hosted client connects to.</summary>
public string ServerUrl { get; set; } = default!;
public IReadOnlyList<McpClientRegistrationItem> Registrations { get; set; } = default!;
}
/// <summary>Body of POST tenants/{tenantId}/mcp-client-registrations.</summary>
public class CreateMcpClientRegistrationRequest : IApiRequest
{
/// <summary>Display name, 1-64 characters.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>The client's exact OAuth callback URL: https, or http on localhost / 127.0.0.1; no query, fragment or wildcard.</summary>
public string RedirectUri { get; set; } = string.Empty;
}
/// <summary>Response of POST tenants/{tenantId}/mcp-client-registrations: the stored registration.</summary>
// Declaration order == wire order.
public class CreateMcpClientRegistrationResponse : IApiResponse
{
public McpClientRegistrationItem Registration { get; set; } = default!;
}
/// <summary>
/// Response of the anonymous GET mcp/client-registrations/{registrationId} — the MCP server's OAuth proxy
/// resolves a client id <c>amc_&lt;registrationId&gt;</c> through it. Carries nothing secret: the tenant id
/// and the callback the registration binds the flow to.
/// </summary>
// Declaration order == wire order.
public class McpClientRegistrationLookupResponse : IApiResponse
{
public string RegistrationId { get; set; } = default!;
public string TenantId { get; set; } = default!;
public string RedirectUri { get; set; } = default!;
public string Name { get; set; } = default!;
}
}
@@ -13,8 +13,10 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Identifies which validator authorized the device during session registration.
/// Surfaced in the RegisterSession response so the agent can reconcile against its
/// own registry-based detection and, when needed, switch its enrollment flow.
/// Stored on the session row and echoed in the RegisterSession response. The agent parses
/// that response strictly (an unknown name fails the registration), so a new member ships
/// with an agent release BEFORE the backend that can send it; agents self-update at every
/// start, so a released agent is what every new registration runs.
/// </summary>
public enum ValidatorType
{
@@ -23,7 +25,8 @@ namespace AutopilotMonitor.Shared.Models
CorporateIdentifier = 2, // CorporateIdentifierValidator (importedDeviceIdentities)
DeviceAssociation = 3, // DeviceAssociationValidator (device preparation tenantAssociatedDevices)
Bootstrap = 4, // Bootstrap token auth (pre-MDM OOBE)
CloudPc = 5 // CloudPcDeviceValidator (virtualEndpoint/cloudPCs, cert-CN bound) — W365
CloudPc = 5, // CloudPcDeviceValidator (virtualEndpoint/cloudPCs, cert-CN bound) — W365
IntuneEnrollment = 6 // IntuneDeviceBindingValidator (managedDevices, cert-CN bound) — no pre-registration
}
/// <summary>
@@ -44,10 +47,8 @@ namespace AutopilotMonitor.Shared.Models
public string? AdminAction { get; set; }
/// <summary>
/// Authoritative signal: which validator accepted this device.
/// Lets the agent reconcile its registry-based enrollment-type detection
/// against the backend's verdict (e.g. AutopilotV1 → Classic flow, DeviceAssociation → DevPrep flow).
/// Older backends that do not set this return Unknown — agent falls back to its own detection.
/// Which validator accepted this device. The agent only logs it and forwards it on the
/// SessionStarted signal; its enrollment flow comes from its own registry detection.
/// </summary>
public ValidatorType ValidatedBy { get; set; } = ValidatorType.Unknown;
@@ -351,8 +352,9 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Which backend device-validation path accepted this device at session registration —
/// <see cref="ValidatorType"/> name as string: "AutopilotV1" (Autopilot S/N lookup),
/// "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc", or
/// "Bootstrap" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap
/// "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc",
/// "IntuneEnrollment" (enrolled Intune device, no pre-registration) or "Bootstrap"
/// (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap
/// session re-registering under cert auth upgrades to the cert-path validator).
/// Empty for sessions that predate this field or tenants with device validation off.
/// </summary>
@@ -66,6 +66,10 @@ interface AdminConfigContextValue {
setSlaNotificationCooldownHours: (value: number) => void;
allowAgentDowngrade: boolean;
setAllowAgentDowngrade: (value: boolean) => void;
enforceClientAppBinding: boolean;
setEnforceClientAppBinding: (value: boolean) => void;
mcpClientRegistrationEnabled: boolean;
setMcpClientRegistrationEnabled: (value: boolean) => void;
modernDeploymentHarmlessEventIds: string;
setModernDeploymentHarmlessEventIds: (value: string) => void;
sessionDeletionKillSwitch: boolean;
@@ -175,6 +179,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const [opsEventRetentionDays, setOpsEventRetentionDays] = useState(90);
const [slaNotificationCooldownHours, setSlaNotificationCooldownHours] = useState(24);
const [allowAgentDowngrade, setAllowAgentDowngrade] = useState(false);
const [enforceClientAppBinding, setEnforceClientAppBinding] = useState(false);
const [mcpClientRegistrationEnabled, setMcpClientRegistrationEnabled] = useState(false);
const [modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds] = useState("100, 1005, 1010");
const [sessionDeletionKillSwitch, setSessionDeletionKillSwitch] = useState(false);
const [autoApproveNewTenants, setAutoApproveNewTenants] = useState(false);
@@ -242,6 +248,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setOpsEventRetentionDays(data.opsEventRetentionDays ?? 90);
setSlaNotificationCooldownHours(data.slaNotificationCooldownHours ?? 24);
setAllowAgentDowngrade(data.allowAgentDowngrade ?? false);
setEnforceClientAppBinding(data.enforceClientAppBinding ?? false);
setMcpClientRegistrationEnabled(data.mcpClientRegistrationEnabled ?? false);
setModernDeploymentHarmlessEventIds(parseHarmlessEventIdsJson(data.modernDeploymentHarmlessEventIdsJson));
setSessionDeletionKillSwitch(data.sessionDeletionKillSwitch ?? false);
setAutoApproveNewTenants(data.autoApproveNewTenants ?? false);
@@ -358,6 +366,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
opsEventRetentionDays,
slaNotificationCooldownHours,
allowAgentDowngrade,
enforceClientAppBinding,
mcpClientRegistrationEnabled,
modernDeploymentHarmlessEventIdsJson: serializeHarmlessEventIds(modernDeploymentHarmlessEventIds),
sessionDeletionKillSwitch,
autoApproveNewTenants,
@@ -381,7 +391,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingConfig(false);
}
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, portalUserRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, imeMsiArchivingEnabled, maxImeMsiDownloadSizeMB, getAccessToken]);
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, portalUserRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, enforceClientAppBinding, mcpClientRegistrationEnabled, modernDeploymentHarmlessEventIds, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, imeMsiArchivingEnabled, maxImeMsiDownloadSizeMB, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
@@ -398,6 +408,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90);
setSlaNotificationCooldownHours(adminConfig.slaNotificationCooldownHours ?? 24);
setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false);
setEnforceClientAppBinding(adminConfig.enforceClientAppBinding ?? false);
setMcpClientRegistrationEnabled(adminConfig.mcpClientRegistrationEnabled ?? false);
setModernDeploymentHarmlessEventIds(parseHarmlessEventIdsJson(adminConfig.modernDeploymentHarmlessEventIdsJson));
setSessionDeletionKillSwitch(adminConfig.sessionDeletionKillSwitch ?? false);
setAutoApproveNewTenants(adminConfig.autoApproveNewTenants ?? false);
@@ -558,6 +570,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
opsEventRetentionDays, setOpsEventRetentionDays,
slaNotificationCooldownHours, setSlaNotificationCooldownHours,
allowAgentDowngrade, setAllowAgentDowngrade,
enforceClientAppBinding, setEnforceClientAppBinding,
mcpClientRegistrationEnabled, setMcpClientRegistrationEnabled,
modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds,
sessionDeletionKillSwitch, setSessionDeletionKillSwitch,
autoApproveNewTenants, setAutoApproveNewTenants,
@@ -29,6 +29,10 @@ interface AdminConfigSettingsSectionProps {
setSlaNotificationCooldownHours: (value: number) => void;
allowAgentDowngrade: boolean;
setAllowAgentDowngrade: (value: boolean) => void;
enforceClientAppBinding: boolean;
setEnforceClientAppBinding: (value: boolean) => void;
mcpClientRegistrationEnabled: boolean;
setMcpClientRegistrationEnabled: (value: boolean) => void;
modernDeploymentHarmlessEventIds: string;
setModernDeploymentHarmlessEventIds: (value: string) => void;
sessionDeletionKillSwitch: boolean;
@@ -71,6 +75,10 @@ export function AdminConfigSettingsSection({
setSlaNotificationCooldownHours,
allowAgentDowngrade,
setAllowAgentDowngrade,
enforceClientAppBinding,
setEnforceClientAppBinding,
mcpClientRegistrationEnabled,
setMcpClientRegistrationEnabled,
modernDeploymentHarmlessEventIds,
setModernDeploymentHarmlessEventIds,
sessionDeletionKillSwitch,
@@ -309,6 +317,40 @@ export function AdminConfigSettingsSection({
</label>
</div>
<div>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="checkbox"
checked={enforceClientAppBinding}
onChange={(e) => setEnforceClientAppBinding(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
/>
<span>
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Enforce client-app binding</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mt-1">
Applies to user tokens that an application other than Autopilot Monitor obtained, for example a self-hosted AI client using on-behalf-of. Portal and MCP sign-ins are never affected. When{" "}<strong>off</strong>{" "}(default), such requests are only measured (request dimensions{" "}<code className="text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">ClientAppBinding</code>{" "}and{" "}<code className="text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">ClientAppId</code>). When{" "}<strong>on</strong>, they are accepted only if the tenant added that application as a service principal member, and the user then has read-only access. Switching takes effect within five minutes.
</p>
</span>
</label>
</div>
<div>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="checkbox"
checked={mcpClientRegistrationEnabled}
onChange={(e) => setMcpClientRegistrationEnabled(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
/>
<span>
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Self-hosted MCP client registration</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mt-1">
When{" "}<strong>on</strong>, Tenant Admins see Settings → Tenant → Self-hosted AI clients and can register the exact OAuth callback of an AI client their organization runs itself; the client then signs in through the MCP server, bound to that tenant. When{" "}<strong>off</strong>{" "}(default), the section is hidden and existing registrations stop working within a few minutes (kill switch).
</p>
</span>
</label>
</div>
<div>
<label className="block">
<span className="text-indigo-900 dark:text-indigo-100 font-medium">ModernDeployment Harmless EventIDs</span>
@@ -1185,6 +1185,24 @@ function TenantManagementSectionInner({
<p className="mt-1 text-xs text-gray-500">Per-user limit for MCP and integration requests made on behalf of this tenant&apos;s standard users. Portal sessions use the global portal budget and are not affected. Leave blank to inherit the global default. Does not apply to Global Admins.</p>
</div>
{/* Self-hosted AI client registrations (raised on request) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Self-hosted AI Client Registrations</label>
<input
type="number"
min="1"
max="10"
placeholder="Blank = default (1)"
value={editingTenant.mcpClientRegistrationLimit ?? ""}
onChange={(e) => {
const v = e.target.value.trim();
setEditingTenant({ ...editingTenant, mcpClientRegistrationLimit: v === "" ? null : (parseInt(v) || null) });
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
<p className="mt-1 text-xs text-gray-500">How many self-hosted AI clients this tenant may register under Settings → Tenant → AI Integration (1–10). Leave blank for the default of one; raise it on request.</p>
</div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
@@ -20,6 +20,8 @@ export function SectionGlobalSettings() {
desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes,
slaNotificationCooldownHours, setSlaNotificationCooldownHours,
allowAgentDowngrade, setAllowAgentDowngrade,
enforceClientAppBinding, setEnforceClientAppBinding,
mcpClientRegistrationEnabled, setMcpClientRegistrationEnabled,
modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds,
sessionDeletionKillSwitch, setSessionDeletionKillSwitch,
autoApproveNewTenants, setAutoApproveNewTenants,
@@ -60,6 +62,10 @@ export function SectionGlobalSettings() {
setSlaNotificationCooldownHours={setSlaNotificationCooldownHours}
allowAgentDowngrade={allowAgentDowngrade}
setAllowAgentDowngrade={setAllowAgentDowngrade}
enforceClientAppBinding={enforceClientAppBinding}
setEnforceClientAppBinding={setEnforceClientAppBinding}
mcpClientRegistrationEnabled={mcpClientRegistrationEnabled}
setMcpClientRegistrationEnabled={setMcpClientRegistrationEnabled}
modernDeploymentHarmlessEventIds={modernDeploymentHarmlessEventIds}
setModernDeploymentHarmlessEventIds={setModernDeploymentHarmlessEventIds}
sessionDeletionKillSwitch={sessionDeletionKillSwitch}
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import type {
AgentConfigResponse,
McpClientRegistrationListResponse,
TenantConfiguration,
TenantFeatureFlagsResponse,
} from "@/utils/wire-types.generated";
@@ -205,6 +206,31 @@ function ChannelRows({ config }: { config: TenantConfiguration }) {
);
}
// ── Self-hosted AI clients (separate table, not part of TenantConfiguration) ──
function SelfHostedAiClientRows({ list }: { list: McpClientRegistrationListResponse }) {
return (
<>
<ConfigRow label="Self-hosted AI (platform switch)" display={list.enabled ? 'On' : 'Off'} />
{list.registrations.length === 0 && <ConfigRow label="Self-hosted AI Clients" display="—" />}
{list.registrations.map((r) => (
<tr key={r.registrationId} className="border-t-2 border-gray-200 dark:border-gray-600">
<td colSpan={2} className="p-0">
<table className="w-full">
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
<ConfigRow label="Self-hosted AI Client" display={r.name} />
<ConfigRow label="Callback URL" display={r.redirectUri} />
<ConfigRow label="Client ID" display={r.clientId} />
<ConfigRow label="Registered" display={`${formatDate(r.createdUtc)} by ${r.createdBy || '—'}`} />
</tbody>
</table>
</td>
</tr>
))}
</>
);
}
// ── Main component ───────────────────────────────────────────────────────────
export function SectionTenantConfigReport() {
@@ -215,6 +241,7 @@ export function SectionTenantConfigReport() {
const [config, setConfig] = useState<TenantConfiguration | null>(null);
const [flags, setFlags] = useState<TenantFeatureFlagsResponse | null>(null);
const [effective, setEffective] = useState<AgentConfigResponse | null>(null);
const [aiClients, setAiClients] = useState<McpClientRegistrationListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadingTenants, setLoadingTenants] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -242,27 +269,31 @@ export function SectionTenantConfigReport() {
fetchTenants();
}, [user?.isGlobalAdmin, getAccessToken]);
// Fetch the stored config plus the two backend-resolved views of it. The stored config is
// the report; feature-flags (effective entitlement, header) and the effective agent config
// (runtime column) are fail-soft — a notice replaces them instead of blocking the page.
// Fetch the stored config plus the two backend-resolved views of it and the self-hosted AI
// client registrations (own table). The stored config is the report; feature-flags (effective
// entitlement, header), the effective agent config (runtime column) and the registrations are
// fail-soft — a notice replaces them instead of blocking the page.
const fetchConfig = useCallback(async () => {
if (!selectedTenantId) return;
try {
setLoading(true);
setError(null);
setSideError(null);
const [configResult, flagsResult, effectiveResult] = await Promise.allSettled([
const [configResult, flagsResult, effectiveResult, aiClientsResult] = await Promise.allSettled([
fetchJson<TenantConfiguration>(api.config.tenant(selectedTenantId), getAccessToken),
fetchJson<TenantFeatureFlagsResponse>(api.config.featureFlags(selectedTenantId), getAccessToken),
fetchJson<AgentConfigResponse>(api.config.effectiveAgentConfig(selectedTenantId), getAccessToken),
fetchJson<McpClientRegistrationListResponse>(api.tenants.mcpClientRegistrations(selectedTenantId), getAccessToken),
]);
if (configResult.status === 'rejected') throw configResult.reason;
setConfig(configResult.value);
setFlags(flagsResult.status === 'fulfilled' ? flagsResult.value : null);
setEffective(effectiveResult.status === 'fulfilled' ? effectiveResult.value : null);
setAiClients(aiClientsResult.status === 'fulfilled' ? aiClientsResult.value : null);
const sideFailures = [
flagsResult.status === 'rejected' ? `feature flags: ${apiErrorText(flagsResult.reason)}` : null,
effectiveResult.status === 'rejected' ? `effective agent config: ${apiErrorText(effectiveResult.reason)}` : null,
aiClientsResult.status === 'rejected' ? `self-hosted AI clients: ${apiErrorText(aiClientsResult.reason)}` : null,
].filter((s): s is string => s !== null);
setSideError(sideFailures.length ? `Backend-resolved views unavailable — ${sideFailures.join('; ')}` : null);
} catch (err) {
@@ -270,6 +301,7 @@ export function SectionTenantConfigReport() {
setConfig(null);
setFlags(null);
setEffective(null);
setAiClients(null);
} finally {
setLoading(false);
}
@@ -433,6 +465,7 @@ export function SectionTenantConfigReport() {
/>
);
})}
{section === 'Tenant Status' && aiClients && <SelfHostedAiClientRows list={aiClients} />}
</Section>
))}
</div>
@@ -98,6 +98,7 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
disabledReason: { section: "Tenant Status", label: "Disabled Reason", informational: true },
disabledUntil: { section: "Tenant Status", label: "Disabled Until", kind: "date", informational: true },
mcpDisabled: { section: "Tenant Status", label: "MCP Disabled" },
mcpClientRegistrationLimit: { section: "Tenant Status", label: "Self-hosted AI Client Limit" },
mcpDisabledReason: { section: "Tenant Status", label: "MCP Disabled Reason", informational: true },
onboardedAt: { section: "Tenant Status", label: "Onboarded At", kind: "date", informational: true },
onboardedBy: { section: "Tenant Status", label: "Onboarded By", informational: true },
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { MAX_SERIAL_PARAM_LENGTH, readSerialParam, withSerialParam } from "../serialDeepLink";
describe("readSerialParam", () => {
it("returns the trimmed serial", () => {
expect(readSerialParam("?serial=ABC123")).toBe("ABC123");
expect(readSerialParam("?serial=%20ABC123%20")).toBe("ABC123");
});
it("decodes device names with spaces and special characters", () => {
expect(readSerialParam("?serial=CPC-user%20A%2B1")).toBe("CPC-user A+1");
});
it("returns null when the parameter is absent, empty or blank", () => {
expect(readSerialParam("")).toBeNull();
expect(readSerialParam("?other=1")).toBeNull();
expect(readSerialParam("?serial=")).toBeNull();
expect(readSerialParam("?serial=%20%20")).toBeNull();
});
it("rejects terms above the length cap", () => {
expect(readSerialParam(`?serial=${"A".repeat(MAX_SERIAL_PARAM_LENGTH)}`)).toHaveLength(MAX_SERIAL_PARAM_LENGTH);
expect(readSerialParam(`?serial=${"A".repeat(MAX_SERIAL_PARAM_LENGTH + 1)}`)).toBeNull();
});
});
describe("withSerialParam", () => {
it("sets the serial and keeps other parameters", () => {
expect(withSerialParam("", "ABC123")).toBe("?serial=ABC123");
expect(withSerialParam("?authapp=legacy&serial=OLD", "NEW 1")).toBe("?authapp=legacy&serial=NEW+1");
});
it("round-trips through readSerialParam", () => {
expect(readSerialParam(withSerialParam("", "CPC-user A+1"))).toBe("CPC-user A+1");
});
});
@@ -0,0 +1,28 @@
/**
* Deep link into the progress page: `/progress?serial=<serial or device name>`.
*
* Organisations send this link to end users (onboarding mail, ticket, workflow) so they
* can follow their device without typing the serial. It grants nothing the form does not:
* sign-in stays mandatory, the tenant comes from the token, and roleless callers still need
* the exact serial or device name server-side. A query parameter, not a fragment, because
* ProtectedRoute keeps only `pathname + search` across the login redirect.
*/
export const SERIAL_PARAM = "serial";
/** Upper bound for a linked term; real serials and device names stay far below it. */
export const MAX_SERIAL_PARAM_LENGTH = 128;
/** The linked search term from a `location.search` string, or null when absent or unusable. */
export function readSerialParam(search: string): string | null {
const value = new URLSearchParams(search).get(SERIAL_PARAM)?.trim();
if (!value || value.length > MAX_SERIAL_PARAM_LENGTH) return null;
return value;
}
/** `location.search` with the serial parameter set to `serial`, other parameters kept. */
export function withSerialParam(search: string, serial: string): string {
const params = new URLSearchParams(search);
params.set(SERIAL_PARAM, serial);
return `?${params.toString()}`;
}
@@ -1,12 +1,13 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { trackEvent } from "@/lib/appInsights";
import { Session } from "@/types";
import { type NotificationType, notifyApiError } from "@/contexts/NotificationContext";
import type { ProgressLookupSessionResponse } from "@/utils/wire-types.generated";
import { ApiError, fetchJson } from "@/lib/apiClient";
import { readSerialParam, withSerialParam } from "./serialDeepLink";
type AddNotification = (
type: NotificationType,
@@ -43,6 +44,8 @@ export interface UseProgressSearchReturn {
* server-side; the tenant-wide list never reaches the browser)
* - exposes `setSession` so real-time refetch can replace the selected session
* - auto-collapses header on match, raises notFound on miss or error
* - runs one lookup for a `?serial=` deep link once the tenant is known, and mirrors every
* hit back into the URL as `?serial=<serial>` so reload, bookmark and a copied link work
*/
export function useProgressSearch({
tenantId,
@@ -57,10 +60,12 @@ export function useProgressSearch({
const [notFound, setNotFound] = useState(false);
const [headerCollapsed, setHeaderCollapsed] = useState(false);
const searchBySerial = useCallback(async () => {
if (!serialInput.trim()) return;
const runSearch = useCallback(async (rawTerm: string, source: "form" | "link") => {
const term = rawTerm.trim();
if (!term) return;
trackEvent("progress_serial_submitted");
trackEvent("progress_serial_submitted", { source });
setSerialInput(term);
setSearching(true);
setSearched(true);
setNotFound(false);
@@ -70,7 +75,7 @@ export function useProgressSearch({
try {
let data: ProgressLookupSessionResponse;
try {
data = await fetchJson<ProgressLookupSessionResponse>(api.progress.lookup(tenantId, serialInput.trim()), getAccessToken);
data = await fetchJson<ProgressLookupSessionResponse>(api.progress.lookup(tenantId, term), getAccessToken);
} catch (err) {
if (!(err instanceof ApiError)) throw err;
notifyApiError(addNotification, "Backend Error", err, "progress-search-error", "Search failed.");
@@ -82,6 +87,12 @@ export function useProgressSearch({
if (found) {
setSession(found);
setHeaderCollapsed(true);
// The serial, not the typed term: a member's partial term or a device name would
// not resolve for a roleless user the link is forwarded to.
const search = withSerialParam(window.location.search, found.serialNumber?.trim() || term);
if (search !== window.location.search) {
window.history.replaceState(null, "", window.location.pathname + search + window.location.hash);
}
} else {
setNotFound(true);
}
@@ -97,7 +108,22 @@ export function useProgressSearch({
} finally {
setSearching(false);
}
}, [serialInput, tenantId, getAccessToken, addNotification, onBeforeSearch]);
}, [tenantId, getAccessToken, addNotification, onBeforeSearch]);
const searchBySerial = useCallback(() => runSearch(serialInput, "form"), [runSearch, serialInput]);
// Deep link: one lookup per page load, once sign-in has resolved the tenant.
const deepLinkHandled = useRef(false);
useEffect(() => {
if (!tenantId || deepLinkHandled.current) return;
deepLinkHandled.current = true;
const linked = readSerialParam(window.location.search);
if (!linked) return;
const run = async () => {
await runSearch(linked, "link");
};
void run();
}, [tenantId, runSearch]);
return {
serialInput,
@@ -13,6 +13,7 @@ import { SectionSubmitLogs } from "../sections/SectionSubmitLogs";
import { SectionContact } from "../sections/SectionContact";
import { SectionOptionalGraphCapabilities } from "../sections/SectionOptionalGraphCapabilities";
import { SectionDelegatedAccess } from "../sections/SectionDelegatedAccess";
import { SectionAiIntegration } from "../sections/SectionAiIntegration";
const SECTION_COMPONENTS: Record<TenantSectionId, React.ComponentType> = {
"plan": SectionPlan,
@@ -22,6 +23,7 @@ const SECTION_COMPONENTS: Record<TenantSectionId, React.ComponentType> = {
"sla-targets": SectionSlaTargets,
"access-management": SectionAccessManagement,
"delegations": SectionDelegatedAccess,
"ai-integration": SectionAiIntegration,
"bootstrap-sessions": SectionBootstrapSessions,
"graph-permissions": SectionOptionalGraphCapabilities,
"contact": SectionContact,
@@ -0,0 +1,279 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useTenant } from "@/contexts/TenantContext";
import { useTenantConfig } from "../../TenantConfigContext";
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { api } from "@/lib/api";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { MCP_SERVER_URL } from "@/utils/config";
import type {
CreateMcpClientRegistrationRequest,
CreateMcpClientRegistrationResponse,
McpClientRegistrationListResponse,
} from "@/utils/wire-types.generated";
function formatDay(iso: string | undefined | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
}
function useCopy() {
const [copied, setCopied] = useState<string | null>(null);
const copy = useCallback(async (value: string, key: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
} catch {
setCopied(null);
}
}, []);
return { copied, copy };
}
/**
* Settings → Tenant → AI Integration. Part 1 explains the normal connection (Claude, ChatGPT, VS Code and
* other supported AI clients: add the server URL, sign in — nothing to register here). Part 2, for Tenant
* Admins while the platform switch is on, registers the exact OAuth callback of an AI client the
* organization hosts itself; that client then connects with client id amc_&lt;id&gt;, bound to this tenant.
*/
export function SectionAiIntegration() {
const { user } = useAuth();
const { canEditConfig } = useTenantConfig();
const { copied, copy } = useCopy();
const showSelfHosted = canEditConfig && (user?.mcpClientRegistrationEnabled ?? false);
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
tone="skyIndigo"
iconPath="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
title="AI Integration"
subtitle="Ask Claude, ChatGPT, VS Code or another AI assistant about your enrollments. The assistant reads your data through the Autopilot Monitor MCP server."
docsPath={DOCS_PATHS.mcpUsers}
/>
<div className="p-6 space-y-3 text-sm text-gray-700">
<p>
<strong>Nothing to register here.</strong> Add this server URL as an MCP server (connector) in your AI client:
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="text-sm bg-gray-100 text-gray-800 rounded px-2 py-1">{MCP_SERVER_URL}</code>
<button type="button" onClick={() => void copy(MCP_SERVER_URL, "server-main")} className="text-xs text-indigo-600 hover:text-indigo-800">
{copied === "server-main" ? "Copied" : "Copy"}
</button>
</div>
<p>
The client opens the Microsoft sign-in in your browser. Sign in with your Autopilot Monitor account; the assistant
then sees exactly what your role allows in the portal. The setup guide covers each client step by step.
</p>
</div>
</div>
{showSelfHosted && <SelfHostedClients />}
</div>
);
}
/** Part 2: registrations of AI clients the organization hosts itself (Tenant Admin, platform switch on). */
function SelfHostedClients() {
const { getAccessToken } = useAuth();
const { tenantId } = useTenant();
const { copied, copy } = useCopy();
const [data, setData] = useState<McpClientRegistrationListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [name, setName] = useState("");
const [redirectUri, setRedirectUri] = useState("");
const notify = (msg: string) => {
setFlash(msg);
setTimeout(() => setFlash(null), 4000);
};
const load = useCallback(async () => {
if (!tenantId) return;
setLoading(true);
setError(null);
try {
setData(await fetchJson<McpClientRegistrationListResponse>(api.tenants.mcpClientRegistrations(tenantId), getAccessToken));
} catch (err) {
setError(apiErrorText(err, "Failed to load self-hosted clients"));
} finally {
setLoading(false);
}
}, [tenantId, getAccessToken]);
useEffect(() => {
const run = async () => {
await load();
};
void run();
}, [load]);
const create = useCallback(async () => {
if (!tenantId) return;
setBusy("create");
setError(null);
try {
const created = await fetchJson<CreateMcpClientRegistrationResponse>(api.tenants.mcpClientRegistrations(tenantId), getAccessToken, {
method: "POST",
body: jsonBody<CreateMcpClientRegistrationRequest>({ name: name.trim(), redirectUri: redirectUri.trim() }),
});
setName("");
setRedirectUri("");
notify(`${created.registration.name} registered. Configure the client with the client ID shown below.`);
await load();
} catch (err) {
setError(apiErrorText(err, "Failed to register the client"));
} finally {
setBusy(null);
}
}, [tenantId, name, redirectUri, getAccessToken, load]);
const remove = useCallback(async (registrationId: string, label: string) => {
if (!tenantId) return;
setBusy(registrationId);
setError(null);
try {
await fetchOk(api.tenants.mcpClientRegistration(tenantId, registrationId), getAccessToken, { method: "DELETE" });
setConfirmDelete(null);
notify(`${label} deleted. The client can no longer sign in.`);
await load();
} catch (err) {
setError(apiErrorText(err, "Failed to delete the registration"));
} finally {
setBusy(null);
}
}, [tenantId, getAccessToken, load]);
const registrations = data?.registrations ?? [];
const atLimit = data ? registrations.length >= data.maxRegistrations : false;
const inputClass = "px-3 py-1.5 border border-gray-300 rounded-lg text-sm bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500";
return (
<div className="space-y-4">
{flash && <div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-800">{flash}</div>}
{error && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">{error}</div>}
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
tone="skyIndigo"
iconPath="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
title="Self-hosted AI clients"
subtitle="Only for an AI client your organization hosts itself. Claude, ChatGPT, VS Code and other hosted assistants need nothing here."
docsPath={DOCS_PATHS.selfHostedClients}
/>
<div className="p-6 space-y-4">
{data && !data.enabled && (
<p className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Self-hosted client registration is currently switched off on this platform. Existing registrations do not work until it is switched on again.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name, e.g. Team AI"
maxLength={64}
className={`${inputClass} w-48`}
aria-label="Client name"
/>
<input
type="url"
value={redirectUri}
onChange={(e) => setRedirectUri(e.target.value)}
placeholder="https://ai.example.com/oauth/callback"
className={`${inputClass} flex-1 min-w-[18rem] font-mono`}
aria-label="Callback URL"
/>
<button
type="button"
onClick={() => void create()}
disabled={busy !== null || atLimit || !data?.enabled || !name.trim() || !redirectUri.trim()}
className="px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{busy === "create" ? "Registering…" : "Register"}
</button>
</div>
{data && (
<p className="text-xs text-gray-500">
{registrations.length} of {data.maxRegistrations} registration{data.maxRegistrations === 1 ? "" : "s"} used; more are available on request. The callback URL must match exactly what the client sends: https, or http only for localhost.
</p>
)}
{loading && !data ? (
<p className="text-sm text-gray-500">Loading…</p>
) : registrations.length === 0 ? (
<p className="text-sm text-gray-500">No self-hosted client is registered.</p>
) : (
<ul className="divide-y divide-gray-100">
{registrations.map((r) => (
<li key={r.registrationId} className="py-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<span className="font-medium text-gray-900">{r.name}</span>
<span className="font-mono text-xs text-gray-600 truncate max-w-[28rem]" title={r.redirectUri}>{r.redirectUri}</span>
<span className="text-xs text-gray-500">
{r.createdBy} · {formatDay(r.createdUtc)}
</span>
<span className="ml-auto flex items-center gap-2">
<code className="text-xs bg-gray-100 text-gray-800 rounded px-1.5 py-0.5">{r.clientId}</code>
<button type="button" onClick={() => void copy(r.clientId, r.registrationId)} className="text-xs text-indigo-600 hover:text-indigo-800">
{copied === r.registrationId ? "Copied" : "Copy client ID"}
</button>
{confirmDelete === r.registrationId ? (
<>
<span className="text-xs text-gray-600">Delete? The client stops working.</span>
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(r.registrationId, r.name)}
className="text-xs font-medium text-white bg-red-600 rounded px-2 py-1 hover:bg-red-700 disabled:opacity-50"
>
{busy === r.registrationId ? "Deleting…" : "Confirm"}
</button>
<button type="button" onClick={() => setConfirmDelete(null)} className="text-xs text-gray-500 hover:text-gray-700">Cancel</button>
</>
) : (
<button type="button" onClick={() => setConfirmDelete(r.registrationId)} className="text-xs text-red-600 hover:text-red-800">
Delete
</button>
)}
</span>
</li>
))}
</ul>
)}
{data && (
<details className="text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg px-4 py-3">
<summary className="cursor-pointer font-medium text-gray-800">How to configure the client</summary>
<ol className="mt-3 space-y-2 list-decimal list-inside">
<li>
Server URL:{" "}
<code className="text-xs bg-white border border-gray-200 rounded px-1.5 py-0.5">{data.serverUrl}</code>{" "}
<button type="button" onClick={() => void copy(data.serverUrl, "server")} className="text-xs text-indigo-600 hover:text-indigo-800">
{copied === "server" ? "Copied" : "Copy"}
</button>
</li>
<li>Authentication: OAuth. Enter the client ID from the list above and leave the client secret empty.</li>
<li>Leave the authorization and token URLs empty; the client discovers them from the server.</li>
<li>Register the exact callback URL your client uses for its OAuth sign-in. The client&apos;s MCP server settings or its documentation show it.</li>
<li>Connecting opens the Microsoft sign-in. Users sign in with their account of this tenant; their role applies as in the portal.</li>
</ol>
</details>
)}
</div>
</div>
</div>
);
}
@@ -5,6 +5,7 @@ export const TENANT_NAV_SECTIONS = [
{ id: "hardware-whitelist", label: "Hardware Whitelist", description: "Manufacturer and model whitelist configuration" },
{ id: "notifications", label: "Notifications", description: "Webhook notification configuration" },
{ id: "sla-targets", label: "SLA Targets", description: "SLA targets and breach notification settings" },
{ id: "ai-integration", label: "AI Integration", description: "Connect AI assistants and self-hosted AI clients through MCP" },
{ id: "bootstrap-sessions", label: "Bootstrap Sessions", description: "Create and manage bootstrap tokens" },
{ id: "graph-permissions", label: "Optional Graph capabilities", description: "Grant additional Microsoft Graph permissions for optional features" },
{ id: "support", label: "Submit Logs", description: "Send diagnostic files to the Autopilot Monitor team" },
@@ -277,6 +277,7 @@ function toUserInfo(data: AuthMeResponse, account: AccountInfo): UserInfo {
hasMcpAccess: data.hasMcpAccess || false,
bootstrapTokenEnabled: data.bootstrapTokenEnabled || false,
unrestrictedModeEnabled: data.unrestrictedModeEnabled || false,
mcpClientRegistrationEnabled: data.mcpClientRegistrationEnabled || false,
// What's new seen marks: absent key = never viewed (first-visit window applies).
whatsNewSeenPlatformUtc: data.whatsNewSeenPlatformUtc,
whatsNewSeenAgentUtc: data.whatsNewSeenAgentUtc,
@@ -304,6 +305,7 @@ function claimsOnlyUserInfo(account: AccountInfo): UserInfo {
hasMcpAccess: false,
bootstrapTokenEnabled: false,
unrestrictedModeEnabled: false,
mcpClientRegistrationEnabled: false,
};
}
@@ -223,6 +223,25 @@ describe("item and sub-item filtering", () => {
expect(subIds(bootstrapOperator)).toContain("cfg-bootstrap-sessions");
expect(subIds(bootstrapOperator)).not.toContain("cfg-agent-unrestricted");
});
it("AI Integration shows for admins and for anyone with MCP access, independent of the self-hosted switch", () => {
const flags = (u: Parameters<typeof user>[0]) =>
deriveNavFlags({ user: user(u), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false });
expect(subIds(flags({ isTenantAdmin: true, role: "Admin" }))).toContain("cfg-ai-integration");
expect(subIds(flags({ role: "Viewer", hasMcpAccess: true }))).toContain("cfg-ai-integration");
expect(subIds(flags({ role: "Operator" }))).not.toContain("cfg-ai-integration");
expect(subIds(flagsOf("GlobalReader+adminModeOn"))).toContain("cfg-ai-integration");
});
it("AI Integration sits between SLA Targets and Bootstrap Sessions", () => {
const ids = subIds(deriveNavFlags({
user: user({ isTenantAdmin: true, role: "Admin", bootstrapTokenEnabled: true }),
hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false,
}));
const at = ids.indexOf("cfg-ai-integration");
expect(ids[at - 1]).toBe("cfg-sla-targets");
expect(ids[at + 1]).toBe("cfg-bootstrap-sessions");
});
});
// ── layer 3: nav ↔ route-guard consistency ────────────────────────────────────
+5
View File
@@ -209,6 +209,11 @@ export const api = {
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/${encodeURIComponent(action)}`,
adminPermissions: (tenantId: string, adminUpn: string) =>
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/permissions`,
// Self-hosted MCP client registrations (Tenant Admin).
mcpClientRegistrations: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/mcp-client-registrations`,
mcpClientRegistration: (tenantId: string, registrationId: string) =>
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/mcp-client-registrations/${encodeURIComponent(registrationId)}`,
offboard: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/offboard`,
offboardFeedback: (tenantId: string) =>
@@ -17,6 +17,7 @@ export const DOCS_PATHS = {
contact: "/reference/settings#contact",
bootstrapSessions: "/reference/bootstrap-script-and-tokens",
mcpUsers: "/integrations/ai-integration-mcp",
selfHostedClients: "/integrations/ai-integration-mcp#self-hosted-ai-clients",
// Settings → Agent
agentParameters: "/reference/settings#agent-parameters",
agentCollectors: "/reference/settings#agent-collectors",
@@ -214,6 +214,7 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
{ id: "cfg-hardware", label: "Hardware Whitelist", href: route("/settings/tenant/hardware-whitelist") },
{ id: "cfg-notifications", label: "Notifications", href: route("/settings/tenant/notifications") },
{ id: "cfg-sla-targets", label: "SLA Targets", href: route("/settings/tenant/sla-targets") },
{ id: "cfg-ai-integration", label: "AI Integration", href: route("/settings/tenant/ai-integration") },
{ id: "cfg-bootstrap-sessions", label: "Bootstrap Sessions", href: route("/settings/tenant/bootstrap-sessions") },
{ id: "cfg-graph-permissions", label: "Optional Graph capabilities", href: route("/settings/tenant/graph-permissions") },
{ id: "cfg-support", label: "Submit Logs", href: route("/settings/tenant/support") },
@@ -17,6 +17,7 @@ export interface NavUser {
canManageBootstrapTokens?: boolean;
bootstrapTokenEnabled?: boolean;
unrestrictedModeEnabled?: boolean;
mcpClientRegistrationEnabled?: boolean;
}
export interface NavVisibilityInput {
@@ -41,6 +42,8 @@ export interface NavFlags {
canManageBootstrapTokens: boolean;
bootstrapTokenEnabled: boolean;
unrestrictedModeEnabled: boolean;
/** Platform switch for self-hosted MCP client registrations. */
mcpClientRegistrationEnabled: boolean;
/** Minimal nav (Progress Portal only): no tenant role and no fleet/platform scope. */
isRegularUser: boolean;
/** The cross-tenant session browser link. */
@@ -69,6 +72,7 @@ export function deriveNavFlags({ user, hasGlobalScope, hasFleetScope, globalAdmi
canManageBootstrapTokens: user?.canManageBootstrapTokens ?? false,
bootstrapTokenEnabled: user?.bootstrapTokenEnabled ?? false,
unrestrictedModeEnabled: user?.unrestrictedModeEnabled ?? false,
mcpClientRegistrationEnabled: user?.mcpClientRegistrationEnabled ?? false,
// Regular users see minimal nav. A read-only Global Reader has platform scope, and a
// delegated MSP admin has fleet scope → both get the group-filtered nav instead.
isRegularUser: !isTenantMember && !hasFleetScope,
@@ -128,6 +132,11 @@ export function filterExpandableNavGroups(
if (sub.id === "cfg-agent-unrestricted") {
return flags.isAdminLike && flags.unrestrictedModeEnabled;
}
// How to connect an AI assistant: for anyone who can use MCP, plus admins and platform scopes.
// The self-hosted registration part inside is Tenant Admin only and behind the platform switch.
if (sub.id === "cfg-ai-integration") {
return flags.hasMcpAccess || flags.isAdminLike || flags.hasGlobalScope;
}
// Tenant-admin-only sub-sections: Operators and Viewers (read-only settings
// viewers) don't see them — matches the in-page "tenant administrators only" gates. A platform
// scope (GA / read-only GlobalReader) keeps the full GA-identical sidebar.
@@ -59,6 +59,9 @@ export const BLOB_URL_PROD = "https://autopilotmonitoreu.blob.core.windows.net";
*/
export const AGENT_DOWNLOAD_URL = "https://download.autopilotmonitor.com/agent";
/** The MCP endpoint every AI client connects to. Counterpart of Constants.McpServerBaseUrl + "/mcp". */
export const MCP_SERVER_URL = "https://mcp.autopilotmonitor.com/mcp";
/**
* Hostnames the portal accepts in a bootstrap response's agentDownloadUrl.
* Keep in sync with ValidateBootstrapCodeFunction.cs, which builds that URL
@@ -32,6 +32,8 @@ export const SHARED_MANIFEST = {
"maxDiagnosticsDownloadSizeMB",
"diagnosticsDownloadTimeoutSeconds",
"mcpAccessPolicy",
"enforceClientAppBinding",
"mcpClientRegistrationEnabled",
"nvdApiKey",
"opsAlertRulesJson",
"opsAlertTelegramEnabled",
@@ -87,6 +89,8 @@ export const SHARED_MANIFEST = {
"maxDiagnosticsDownloadSizeMB": 500,
"diagnosticsDownloadTimeoutSeconds": 120,
"mcpAccessPolicy": "WhitelistOnly",
"enforceClientAppBinding": false,
"mcpClientRegistrationEnabled": false,
"nvdApiKey": null,
"opsAlertRulesJson": null,
"opsAlertTelegramEnabled": false,
@@ -136,6 +140,7 @@ export const SHARED_MANIFEST = {
"disabledUntil",
"mcpDisabled",
"mcpDisabledReason",
"mcpClientRegistrationLimit",
"customRateLimitRequestsPerMinute",
"customUserRateLimitRequestsPerMinute",
"planTier",
@@ -241,6 +246,7 @@ export const SHARED_MANIFEST = {
"disabledUntil": null,
"mcpDisabled": false,
"mcpDisabledReason": null,
"mcpClientRegistrationLimit": null,
"customRateLimitRequestsPerMinute": null,
"customUserRateLimitRequestsPerMinute": null,
"planTier": "free",
@@ -629,6 +635,7 @@ export const SHARED_MANIFEST = {
"InsufficientPermissions",
"CrossTenantAccessDenied",
"TenantSuspended",
"ClientAppNotRegistered",
"QuotaExceeded",
"PayloadTooLarge",
"TelemetryItemsRejected",
@@ -28,6 +28,8 @@
"maxDiagnosticsDownloadSizeMB",
"diagnosticsDownloadTimeoutSeconds",
"mcpAccessPolicy",
"enforceClientAppBinding",
"mcpClientRegistrationEnabled",
"nvdApiKey",
"opsAlertRulesJson",
"opsAlertTelegramEnabled",
@@ -83,6 +85,8 @@
"maxDiagnosticsDownloadSizeMB": 500,
"diagnosticsDownloadTimeoutSeconds": 120,
"mcpAccessPolicy": "WhitelistOnly",
"enforceClientAppBinding": false,
"mcpClientRegistrationEnabled": false,
"nvdApiKey": null,
"opsAlertRulesJson": null,
"opsAlertTelegramEnabled": false,
@@ -132,6 +136,7 @@
"disabledUntil",
"mcpDisabled",
"mcpDisabledReason",
"mcpClientRegistrationLimit",
"customRateLimitRequestsPerMinute",
"customUserRateLimitRequestsPerMinute",
"planTier",
@@ -237,6 +242,7 @@
"disabledUntil": null,
"mcpDisabled": false,
"mcpDisabledReason": null,
"mcpClientRegistrationLimit": null,
"customRateLimitRequestsPerMinute": null,
"customUserRateLimitRequestsPerMinute": null,
"planTier": "free",
@@ -625,6 +631,7 @@
"InsufficientPermissions",
"CrossTenantAccessDenied",
"TenantSuspended",
"ClientAppNotRegistered",
"QuotaExceeded",
"PayloadTooLarge",
"TelemetryItemsRejected",
@@ -1412,6 +1419,22 @@
},
"doc": "Controls who can access the remote MCP server (evaluated by McpUserService.IsAllowedAsync). \"Disabled\" = MCP off for everyone, platform roles included. \"WhitelistOnly\" = platform roles, delegated (MSP) admins and enabled McpUsers rows (default). \"AllMembers\" = the same grants plus every account holding an effective member role (Admin / Operator / Viewer) in the tenant its token was issued for — never \"any authenticated token\": an employee without a role, or a tenant that never onboarded, is denied. An explicitly disabled McpUsers row denies under every policy."
},
{
"name": "enforceClientAppBinding",
"type": {
"kind": "primitive",
"name": "boolean"
},
"doc": "Client-app binding of delegated tokens. A user token obtained by an application other than the platform's own registrations (a self-hosted AI client, an in-house tool) is always measured. When true it is also enforced: the token is accepted only while that application is an enabled member of the caller's tenant, and the caller then has at most Viewer rights, no platform role and at most DelegatedReader on managed tenants. Default false: measure only."
},
{
"name": "mcpClientRegistrationEnabled",
"type": {
"kind": "primitive",
"name": "boolean"
},
"doc": "Self-hosted MCP client registrations. When true, Tenant Admins can register the exact OAuth callback of a client their organization runs itself and connect it with client id amc_<id>; the MCP server binds that flow to the registering tenant. When false the portal section is hidden and every existing registration stops resolving (kill switch). Default false."
},
{
"name": "nvdApiKey",
"type": {
@@ -4284,6 +4307,14 @@
"name": "boolean"
}
},
{
"name": "mcpClientRegistrationEnabled",
"type": {
"kind": "primitive",
"name": "boolean"
},
"doc": "Whether Tenant Admins can register self-hosted MCP clients (platform switch); drives the settings section."
},
{
"name": "whatsNewSeenPlatformUtc",
"type": {
@@ -5934,6 +5965,41 @@
}
]
},
"CreateMcpClientRegistrationRequest": {
"kind": "object",
"doc": "Body of POST tenants/{tenantId}/mcp-client-registrations.",
"fields": [
{
"name": "name",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Display name, 1-64 characters."
},
{
"name": "redirectUri",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "The client's exact OAuth callback URL: https, or http on localhost / 127.0.0.1; no query, fragment or wildcard."
}
]
},
"CreateMcpClientRegistrationResponse": {
"kind": "object",
"doc": "Response of POST tenants/{tenantId}/mcp-client-registrations: the stored registration.",
"fields": [
{
"name": "registration",
"type": {
"kind": "ref",
"name": "McpClientRegistrationItem"
}
}
]
},
"CreateTenantGroupRequest": {
"kind": "object",
"doc": "Body of POST global/tenant-groups.",
@@ -13270,6 +13336,129 @@
}
]
},
"McpClientRegistrationItem": {
"kind": "object",
"doc": "One self-hosted MCP client registration as the portal lists it.",
"fields": [
{
"name": "registrationId",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "clientId",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "The OAuth client id the self-hosted client is configured with: amc_ + registration id."
},
{
"name": "name",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "redirectUri",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "createdBy",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "createdUtc",
"type": {
"kind": "primitive",
"name": "string"
}
}
]
},
"McpClientRegistrationListResponse": {
"kind": "object",
"doc": "Response of GET tenants/{tenantId}/mcp-client-registrations.",
"fields": [
{
"name": "enabled",
"type": {
"kind": "primitive",
"name": "boolean"
},
"doc": "Whether the platform currently accepts self-hosted client registrations (operator switch)."
},
{
"name": "maxRegistrations",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "How many registrations a tenant may hold."
},
{
"name": "serverUrl",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "The MCP server URL the self-hosted client connects to."
},
{
"name": "registrations",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "McpClientRegistrationItem"
}
}
}
]
},
"McpClientRegistrationLookupResponse": {
"kind": "object",
"doc": "Response of the anonymous GET mcp/client-registrations/{registrationId} — the MCP server's OAuth proxy resolves a client id amc_<registrationId> through it. Carries nothing secret: the tenant id and the callback the registration binds the flow to.",
"fields": [
{
"name": "registrationId",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "tenantId",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "redirectUri",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "name",
"type": {
"kind": "primitive",
"name": "string"
}
}
]
},
"McpHealthCheckResponse": {
"kind": "object",
"doc": "Response of GET health/mcp: the standalone MCP-server reachability probe.",
@@ -15487,7 +15676,7 @@
"kind": "enum",
"name": "ValidatorType"
},
"doc": "Authoritative signal: which validator accepted this device. Lets the agent reconcile its registry-based enrollment-type detection against the backend's verdict (e.g. AutopilotV1 → Classic flow, DeviceAssociation → DevPrep flow). Older backends that do not set this return Unknown — agent falls back to its own detection."
"doc": "Which validator accepted this device. The agent only logs it and forwards it on the SessionStarted signal; its enrollment flow comes from its own registry detection."
},
{
"name": "errorCode",
@@ -19262,7 +19451,7 @@
"kind": "primitive",
"name": "string"
},
"doc": "Which backend device-validation path accepted this device at session registration — ValidatorType name as string: \"AutopilotV1\" (Autopilot S/N lookup), \"CorporateIdentifier\", \"DeviceAssociation\" (device preparation), \"CloudPc\", or \"Bootstrap\" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off."
"doc": "Which backend device-validation path accepted this device at session registration — ValidatorType name as string: \"AutopilotV1\" (Autopilot S/N lookup), \"CorporateIdentifier\", \"DeviceAssociation\" (device preparation), \"CloudPc\", \"IntuneEnrollment\" (enrolled Intune device, no pre-registration) or \"Bootstrap\" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off."
},
{
"name": "eventCount",
@@ -21538,6 +21727,16 @@
"optional": true,
"doc": "Optional reason why MCP access was disabled. Shown to the caller in the 403 response."
},
{
"name": "mcpClientRegistrationLimit",
"type": {
"kind": "primitive",
"name": "number",
"nullable": true
},
"optional": true,
"doc": "How many self-hosted MCP client registrations this tenant may hold. Null = the platform default (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction GA gate, TenantConfigPatchService.GaOnlyFields)."
},
{
"name": "customRateLimitRequestsPerMinute",
"type": {
@@ -21682,7 +21881,7 @@
"kind": "primitive",
"name": "boolean"
},
"doc": "Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number via importedDeviceIdentities/searchExistingIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.ReadWrite.All)"
"doc": "Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number, read from importedDeviceIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All)"
},
{
"name": "validateDeviceAssociation",
@@ -21706,7 +21905,7 @@
"kind": "primitive",
"name": "boolean"
},
"doc": "Cert-to-device binding check (Global-Admin-only preview, SHADOW mode). Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory, proving the certificate belongs to a device the tenant actually enrolled. The result is recorded as telemetry only and never blocks enrollment, because a device object can in principle appear later than the agent's first call - measuring that race is the point of the shadow pass. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature \"IntuneDeviceBinding\" in the grant script)."
"doc": "Intune Enrollment Validation: the last accepting method of the device-validation gate. Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory and admits the device when it is an enrolled device of the tenant, without any pre-registration (Autopilot hash, corporate identifier, device association). Evaluated only when no earlier validator admitted the device. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature \"IntuneDeviceBinding\" in the grant script)."
},
{
"name": "allowInsecureAgentRequests",
@@ -21714,7 +21913,7 @@
"kind": "primitive",
"name": "boolean"
},
"doc": "Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when ValidateAutopilotDevice is false. Default: false"
"doc": "Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when no device validation is enabled (see HasAnyDeviceValidation). Default: false"
},
{
"name": "dataRetentionDays",
@@ -22435,12 +22634,12 @@
"doc": "Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail."
},
{
"name": "validateAutopilotDevice",
"name": "deviceValidationEnabled",
"type": {
"kind": "primitive",
"name": "boolean"
},
"doc": "Drives the \"Autopilot Device Validation disabled\" dashboard banner."
"doc": "At least one device-validation method is enabled (HasAnyDeviceValidation); false drives the \"agent ingestion is blocked\" dashboard banner."
},
{
"name": "appHomingFunnelActive",
@@ -24373,14 +24572,15 @@
},
"ValidatorType": {
"kind": "enum",
"doc": "Identifies which validator authorized the device during session registration. Surfaced in the RegisterSession response so the agent can reconcile against its own registry-based detection and, when needed, switch its enrollment flow.",
"doc": "Identifies which validator authorized the device during session registration. Stored on the session row and echoed in the RegisterSession response. The agent parses that response strictly (an unknown name fails the registration), so a new member ships with an agent release BEFORE the backend that can send it; agents self-update at every start, so a released agent is what every new registration runs.",
"members": [
"Unknown",
"AutopilotV1",
"CorporateIdentifier",
"DeviceAssociation",
"Bootstrap",
"CloudPc"
"CloudPc",
"IntuneEnrollment"
]
},
"VerdictCalibrationAlert": {
@@ -140,6 +140,10 @@ export interface AdminConfiguration {
diagnosticsDownloadTimeoutSeconds: number;
/** Controls who can access the remote MCP server (evaluated by McpUserService.IsAllowedAsync). "Disabled" = MCP off for everyone, platform roles included. "WhitelistOnly" = platform roles, delegated (MSP) admins and enabled McpUsers rows (default). "AllMembers" = the same grants plus every account holding an effective member role (Admin / Operator / Viewer) in the tenant its token was issued for — never "any authenticated token": an employee without a role, or a tenant that never onboarded, is denied. An explicitly disabled McpUsers row denies under every policy. */
mcpAccessPolicy: string;
/** Client-app binding of delegated tokens. A user token obtained by an application other than the platform's own registrations (a self-hosted AI client, an in-house tool) is always measured. When true it is also enforced: the token is accepted only while that application is an enabled member of the caller's tenant, and the caller then has at most Viewer rights, no platform role and at most DelegatedReader on managed tenants. Default false: measure only. */
enforceClientAppBinding: boolean;
/** Self-hosted MCP client registrations. When true, Tenant Admins can register the exact OAuth callback of a client their organization runs itself and connect it with client id amc_<id>; the MCP server binds that flow to the registering tenant. When false the portal section is hidden and every existing registration stops resolving (kill switch). Default false. */
mcpClientRegistrationEnabled: boolean;
/** NVD API key for higher rate limits (50 req/30s vs 5 req/30s without key). Free registration at https://nvd.nist.gov/developers/request-an-api-key null = operate without API key (slower, still functional). */
nvdApiKey: string;
/** JSON-serialized list of OpsAlertRule objects defining which event types trigger notifications. Provider-agnostic — rules apply to all enabled providers. */
@@ -764,6 +768,8 @@ export interface AuthMeResponse {
homedApp: string;
bootstrapTokenEnabled: boolean;
unrestrictedModeEnabled: boolean;
/** Whether Tenant Admins can register self-hosted MCP clients (platform switch); drives the settings section. */
mcpClientRegistrationEnabled: boolean;
/** When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit). */
whatsNewSeenPlatformUtc?: string;
/** Same for the Agent tab; null = never and the key is omitted. */
@@ -1152,6 +1158,19 @@ export interface CreateDelegationInvitationResponse {
expiresUtc: string;
}
/** Body of POST tenants/{tenantId}/mcp-client-registrations. */
export interface CreateMcpClientRegistrationRequest {
/** Display name, 1-64 characters. */
name: string;
/** The client's exact OAuth callback URL: https, or http on localhost / 127.0.0.1; no query, fragment or wildcard. */
redirectUri: string;
}
/** Response of POST tenants/{tenantId}/mcp-client-registrations: the stored registration. */
export interface CreateMcpClientRegistrationResponse {
registration: McpClientRegistrationItem;
}
/** Body of POST global/tenant-groups. */
export interface CreateTenantGroupRequest {
name: string;
@@ -2810,6 +2829,36 @@ export interface ManagedTenantListResponse {
tenants: ManagedTenantItem[];
}
/** One self-hosted MCP client registration as the portal lists it. */
export interface McpClientRegistrationItem {
registrationId: string;
/** The OAuth client id the self-hosted client is configured with: amc_ + registration id. */
clientId: string;
name: string;
redirectUri: string;
createdBy: string;
createdUtc: string;
}
/** Response of GET tenants/{tenantId}/mcp-client-registrations. */
export interface McpClientRegistrationListResponse {
/** Whether the platform currently accepts self-hosted client registrations (operator switch). */
enabled: boolean;
/** How many registrations a tenant may hold. */
maxRegistrations: number;
/** The MCP server URL the self-hosted client connects to. */
serverUrl: string;
registrations: McpClientRegistrationItem[];
}
/** Response of the anonymous GET mcp/client-registrations/{registrationId} — the MCP server's OAuth proxy resolves a client id amc_<registrationId> through it. Carries nothing secret: the tenant id and the callback the registration binds the flow to. */
export interface McpClientRegistrationLookupResponse {
registrationId: string;
tenantId: string;
redirectUri: string;
name: string;
}
/** Response of GET health/mcp: the standalone MCP-server reachability probe. */
export interface McpHealthCheckResponse {
timestamp: string;
@@ -3321,7 +3370,7 @@ export interface RegisterSessionResponse {
registeredAt: string;
/** Non-null when the session was already marked as terminal by an admin before agent restart. Values: "Succeeded", "Failed". Agent should run cleanup instead of starting monitoring. */
adminAction?: string;
/** Authoritative signal: which validator accepted this device. Lets the agent reconcile its registry-based enrollment-type detection against the backend's verdict (e.g. AutopilotV1 → Classic flow, DeviceAssociation → DevPrep flow). Older backends that do not set this return Unknown — agent falls back to its own detection. */
/** Which validator accepted this device. The agent only logs it and forwards it on the SessionStarted signal; its enrollment flow comes from its own registry detection. */
validatedBy: ValidatorType;
/** Machine-readable reason on a failure response (see AgentErrorCodes). Null on success and on failures that carry no agent-actionable code. */
errorCode?: string;
@@ -4171,7 +4220,7 @@ export interface SessionSummary {
completionSource: string;
/** Non-null only when an administrator explicitly flipped the session via the portal (MarkSessionSucceeded / MarkSessionFailed). Values: null (default, agent-driven), "Succeeded", "Failed". This is the authoritative source for the backend's AdminAction response field sent to agents. Previously the backend inferred admin-override from "status is terminal + current event is not a completion marker", which fired falsely on every post-completion event the agent sent (agent_shutting_down, diagnostics_uploaded, enrollment_summary_shown). The dedicated field eliminates that false-positive. */
adminMarkedAction?: string;
/** Which backend device-validation path accepted this device at session registration — ValidatorType name as string: "AutopilotV1" (Autopilot S/N lookup), "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc", or "Bootstrap" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off. */
/** Which backend device-validation path accepted this device at session registration — ValidatorType name as string: "AutopilotV1" (Autopilot S/N lookup), "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc", "IntuneEnrollment" (enrolled Intune device, no pre-registration) or "Bootstrap" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off. */
validatedBy: string;
eventCount: number;
durationSeconds?: number;
@@ -4694,6 +4743,8 @@ export interface TenantConfiguration {
mcpDisabled: boolean;
/** Optional reason why MCP access was disabled. Shown to the caller in the 403 response. */
mcpDisabledReason?: string | null;
/** How many self-hosted MCP client registrations this tenant may hold. Null = the platform default (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction GA gate, TenantConfigPatchService.GaOnlyFields). */
mcpClientRegistrationLimit?: number | null;
/** Optional per-tenant override for the device (agent/cert) API rate limit. If null, the effective limit is the global AdminConfiguration.GlobalRateLimitRequestsPerMinute. If set, this value takes precedence. Global-Admin-only (see UpdateTenantConfigurationFunction GA-gate). */
customRateLimitRequestsPerMinute?: number | null;
/** Optional per-tenant override for the user (portal/JWT) API rate limit applied to standard users (Tenant Admins, Operators, Viewers). If null, the effective limit is the global AdminConfiguration.UserRateLimitRequestsPerMinute. Global-Admin-only. Note: Global Admins are rate-limited by the global GlobalAdminRateLimitRequestsPerMinute (cross-tenant), so this override does not apply to them. */
@@ -4724,15 +4775,15 @@ export interface TenantConfiguration {
modelWhitelist: string;
/** Whether to validate devices against Intune Autopilot device registration Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All) */
validateAutopilotDevice: boolean;
/** Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number via importedDeviceIdentities/searchExistingIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.ReadWrite.All) */
/** Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number, read from importedDeviceIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All) */
validateCorporateIdentifier: boolean;
/** Whether to validate devices against the Windows Autopilot device preparation "Device association" catalog via Graph (tenantAssociatedDevices, serial number match). One of the accepting methods of the device-validation gate, evaluated after the Autopilot and Corporate Identifier lookups (see SecurityValidator). Device association is GA since 2026-08-25; associated devices are marked corporate-owned by Intune itself, so no corporate identifier exists for them. Requires the same Graph permission as the other validators (DeviceManagementServiceConfig.Read.All). */
validateDeviceAssociation: boolean;
/** Whether to validate Windows 365 Cloud PCs as a fallback when the Autopilot / Corporate Identifier lookups miss. Cloud PCs are provisioned by the Windows 365 service and are structurally never Autopilot-registered; this validator instead resolves the Intune device id from the (chain-validated) MDM client certificate's Subject CN and requires a matching cloudPC object (virtualEndpoint/cloudPCs, managedDeviceId eq CN) in the tenant. Only service-provisioned Cloud PCs have such an object, so no other enrolled device can pass this stage. Requires the optional Graph permission CloudPC.Read.All (feature "W365CloudPcValidation" in the grant script). */
validateCloudPcDevice: boolean;
/** Cert-to-device binding check (Global-Admin-only preview, SHADOW mode). Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory, proving the certificate belongs to a device the tenant actually enrolled. The result is recorded as telemetry only and never blocks enrollment, because a device object can in principle appear later than the agent's first call - measuring that race is the point of the shadow pass. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature "IntuneDeviceBinding" in the grant script). */
/** Intune Enrollment Validation: the last accepting method of the device-validation gate. Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory and admits the device when it is an enrolled device of the tenant, without any pre-registration (Autopilot hash, corporate identifier, device association). Evaluated only when no earlier validator admitted the device. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature "IntuneDeviceBinding" in the grant script). */
validateIntuneDeviceBinding: boolean;
/** Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when ValidateAutopilotDevice is false. Default: false */
/** Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when no device validation is enabled (see HasAnyDeviceValidation). Default: false */
allowInsecureAgentRequests: boolean;
/** Data retention period in days Sessions and events older than this will be deleted by the daily maintenance job Default: 90 days */
dataRetentionDays: number;
@@ -4902,8 +4953,8 @@ export interface TenantFeatureFlagsResponse {
bootstrapTokenEnabled: boolean;
/** Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail. */
diagnosticsUploadConfigured: boolean;
/** Drives the "Autopilot Device Validation disabled" dashboard banner. */
validateAutopilotDevice: boolean;
/** At least one device-validation method is enabled (HasAnyDeviceValidation); false drives the "agent ingestion is blocked" dashboard banner. */
deviceValidationEnabled: boolean;
/** Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids. */
appHomingFunnelActive: boolean;
showScriptOutput: boolean;
@@ -5353,8 +5404,8 @@ export interface UserUsageRecord {
lastRequestAt: string;
}
/** Identifies which validator authorized the device during session registration. Surfaced in the RegisterSession response so the agent can reconcile against its own registry-based detection and, when needed, switch its enrollment flow. */
export type ValidatorType = "Unknown" | "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap" | "CloudPc";
/** Identifies which validator authorized the device during session registration. Stored on the session row and echoed in the RegisterSession response. The agent parses that response strictly (an unknown name fails the registration), so a new member ships with an agent release BEFORE the backend that can send it; agents self-update at every start, so a released agent is what every new registration runs. */
export type ValidatorType = "Unknown" | "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap" | "CloudPc" | "IntuneEnrollment";
/** One ACTIVE verdict-calibration alert episode (operator-only). Persisted as the verdictcalibration|{kind}|{path}|{status} keyspace of the notification tracker — the row IS the dedup (one ops event per episode) and the alerts[] payload of the calibration endpoint. Deleted when the signal re-arms (share back under 1.5× baseline, path stops occurring, evidence-gap share back under 15 %) or by the tracker's 30-day retention sweep. Numbers refresh on every radar pass; FirstNotifiedAt never moves. Dimension concentration is CORRELATION only — every consumer says so. */
export interface VerdictCalibrationAlert {