Compare commits

...

32 Commits

Author SHA1 Message Date
Oliver Kieselbach 5a182f3f16 bump pointer: internal (B-5p4 DO-geo deploy/verify)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:10:32 +02:00
Oliver Kieselbach 889b843d6d feat(agent): Delivery Optimization geo endpoint as last-resort geolocation provider
When ipinfo.io (plus retry) and ifconfig.co both fail, the agent now asks
geo.prod.do.dsp.mp.microsoft.com/geo. It returns only CountryCode and
ExternalIpAddress, but it belongs to the DO/Windows Update endpoint set Autopilot
needs anyway, so it stays reachable where public geo services are blocked.
The session then gets a country-only device_location (source "do-geo", no
timezone, so timezone auto-set stays pending) instead of none.

- GeoLocationService: the three providers share one TryProvider skeleton;
  response parsers are internal and unit-tested; GeoLocationResult.Describe()
  skips missing parts ("DE" instead of ", , DE") in the event message and log.
- Failure trace carries doGeoError + lastResortProvider.
- Backend: geographic location keys skip empty region/city, so country-only
  sessions group as "DE" rather than ", DE"; drilldown still round-trips.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:08:56 +02:00
Oliver Kieselbach 9b91119239 bump pointer: internal (way 3 docs live)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:58:33 +02:00
Oliver Kieselbach 1e74d67da9 fix(web): service-principal member form names both uses — automation (access_as_application) and an app sending its users' tokens (delegated access_as_user, users read-only)
The ClientAppNotRegistered refusal (D-279, now enforced) sends Tenant Admins
to this form; its hint required access_as_application, which an app acting
on behalf of users does not need.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:51:52 +02:00
Oliver Kieselbach ed013bbdda bump pointers: internal (hash repair via v2.0.1466), .claude/commands (platform-health repair note)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:51:37 +02:00
Oliver Kieselbach 4b40619d1d bump pointer: internal (client-app binding enforced, way 3 documented)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:48:21 +02:00
Oliver Kieselbach 3ce1f2b036 bump pointer: internal (D-285 plan live)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:06:30 +02:00
Oliver Kieselbach 673f045212 fix(web): platform settings saves send only the changed fields via PATCH global/config (D-285)
Each save handler still builds the edited configuration but now sends changedAdminConfigFields(loaded, edited) through lib/adminConfigSave.ts, and nothing at all when nothing changed. This covers the Global Settings, the diagnostics paths, the ops alerts and the vulnerability section. The MCP access policy patches its single field without reading first. A page loaded before another writer (the agent release pipeline, a second operator, a sync job) can no longer revert that writer's fields. Deployed after the backend that serves PATCH global/config.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:55:43 +02:00
Oliver Kieselbach df640e3684 fix(backend): admin configuration is written only in its changed columns; PATCH global/config replaces the full-model PUT (D-285)
On 2026-09-25 a Global Settings save from a page loaded before an agent release wrote the whole configuration back and reverted the release's LatestAgentV2* hashes. After that, every 2.0.1465 agent reported IntegrityCheckFailed. The flaw was structural: every writer (portal PUT, plan tiers, three vulnerability-sync stamp paths) replaced the whole row from the copy it held, usually the 5-minute cache.

- IConfigRepository.UpdateAdminConfigurationAsync(mutate, updatedBy) is the one write path. It reads the row fresh with its ETag and applies the mutation to a copy materialized from that read. It computes the changed columns from the converter projections and writes only those onto the freshly read raw entity; a null value drops the column. The replace is conditional, and a conflict triggers a re-read and retry. A no-op writes nothing. Pipeline-owned and unknown columns survive. The full-model SaveAdminConfigurationAsync / SaveConfigurationAsync are removed, and the first row is created with AddEntity.
- PATCH global/config with { fields } (PatchAdminConfigurationRequest) replaces PUT/POST. Its field gate is derived from the persisted columns and rejects server stamps, LatestAgentV2* / LatestBootstrapV2ScriptVersion, PlanTierDefinitionsJson, the sync stamps and redacted placeholders. The route catalog lists PATCH.
- Plan tiers, TriggerMsrcSync, TriggerVulnerabilityDataSync, VulnerabilityDataSyncFunction and VulnerabilityCacheRefreshService.StampAsync each hand in a mutation.
- Tests: AdminConfigurationChangedFieldsTests (stale-page toggle keeps the release hashes, concurrent pipeline write re-read and kept, no-op, rejection, null drop, first row, gate); existing tests adapted; the request guard allows the admin field map.
- Regenerated shared manifest and wire types (web utils + MCP). The web still sends PUT until the web commit that follows the backend deploy.
- The internal pointer is bumped to D-285.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:51:53 +02:00
Oliver Kieselbach 59bea9d888 bump pointers: internal (plan envelope live), .claude/commands (platform-health markers)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:11:39 +02:00
Oliver Kieselbach e2c91afda2 feat(mcp): seal refresh tokens of registered self-hosted clients so that deleting the registration also ends copies (D-283)
A portal-registered client (amc_) now receives its Entra refresh token only as a compact JWE (RFC 7516, dir + A256GCM, kid amc-rt-v1). The key is derived from OAuthStateSigningKey. At refresh, the registration comes from the envelope, never from the client_id parameter. Before, a copy of the token redeemed without client_id went to the organizations authority and skipped the registration check.

A raw refresh token presented with an amc_ client id is accepted and returned sealed, so these clients migrate on their next refresh. All other clients are unchanged. jose becomes a production dependency; it was a dev-only transitive dependency before. The internal pointer is bumped to D-283/D-284.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:00:42 +02:00
Oliver Kieselbach eb47c4d69a bump pointer
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:32:14 +02:00
Oliver Kieselbach 91d56f2bbb bump pointer
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:58:20 +02:00
Oliver Kieselbach 12b847360c feat(web): Intune Enrollment Validation in the tenant settings; banners follow any device validation (D-282)
The option is a regular tenant-admin toggle in its own "Without pre-registration" block, with the
add-on permission state read from the existing graph-permissions status endpoint. The dashboard
banner and the admin Ready badge key on "any validation method enabled" (feature flag
deviceValidationEnabled, manifest-driven validate* fields) instead of Autopilot alone, so a
device-preparation tenant that validates by device association is no longer told that agent
ingestion is blocked. Stale "shadow mode" wording for Device Association is gone, the
Not-registered report names every method, sessions show "Intune Enrollment", and the privacy
page and FAQ list the new method. Grant script help describes the IntuneDeviceBinding feature
as Intune Enrollment Validation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:57:42 +02:00
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
138 changed files with 5791 additions and 694 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
@@ -29,10 +29,9 @@
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
of Cloud PC first-connect enrollment)
- IntuneDeviceBinding -> DeviceManagementManagedDevices.Read.All
(cert-to-device binding: verifies that the Intune device id carried in the
agent's client certificate is a device THIS tenant actually enrolled --
currently a Global-Admin-only preview that records telemetry and does not
block enrollment)
(Intune Enrollment Validation: accepts devices enrolled in THIS tenant's
Intune, matched by the Intune device id in the agent's client certificate,
without Autopilot registration, corporate identifier or device association)
- All -> every optional permission above, in one run
(also works with -Revoke to remove all optional grants at once)
@@ -0,0 +1,88 @@
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
{
/// <summary>
/// Response parsers of the geo providers. Addresses come from the RFC 5737 documentation
/// ranges — never real ones.
/// </summary>
public class GeoLocationServiceTests
{
[Fact]
public void ParseDoGeo_maps_country_and_egress_ip_and_ignores_the_do_service_fields()
{
var json = JObject.Parse(@"{
""ExternalIpAddress"": ""203.0.113.7"",
""CountryCode"": ""DE"",
""KeyValue_EndpointFullUri"": ""https://kv601.prod.do.dsp.mp.microsoft.com/all"",
""Version"": ""26A6D4D6994E8DA5"",
""CacheId"": ""7"",
""ContentCert"": false,
""DownloadModeFailSafe"": """"
}");
var result = GeoLocationService.ParseDoGeo(json);
Assert.NotNull(result);
Assert.Equal("DE", result.Country);
Assert.Equal("203.0.113.7", result.Ip);
Assert.Equal("do-geo", result.Source);
Assert.Null(result.Region);
Assert.Null(result.City);
Assert.Null(result.Loc);
Assert.Null(result.Timezone);
}
[Theory]
[InlineData(@"{ ""ExternalIpAddress"": ""203.0.113.7"" }")]
[InlineData(@"{ ""ExternalIpAddress"": ""203.0.113.7"", ""CountryCode"": """" }")]
public void ParseDoGeo_without_country_is_a_failure(string body)
{
Assert.Null(GeoLocationService.ParseDoGeo(JObject.Parse(body)));
}
[Fact]
public void ParseIpInfo_maps_all_fields()
{
var json = JObject.Parse(@"{
""ip"": ""198.51.100.4"", ""city"": ""Stuttgart"", ""region"": ""Baden-Wurttemberg"",
""country"": ""DE"", ""loc"": ""48.7823,9.1770"", ""timezone"": ""Europe/Berlin""
}");
var result = GeoLocationService.ParseIpInfo(json);
Assert.Equal("DE", result.Country);
Assert.Equal("Baden-Wurttemberg", result.Region);
Assert.Equal("Stuttgart", result.City);
Assert.Equal("48.7823,9.1770", result.Loc);
Assert.Equal("Europe/Berlin", result.Timezone);
Assert.Equal("198.51.100.4", result.Ip);
Assert.Equal("ipinfo", result.Source);
}
[Fact]
public void ParseIfConfigCo_joins_coordinates_and_leaves_loc_empty_without_them()
{
var withCoords = GeoLocationService.ParseIfConfigCo(JObject.Parse(@"{
""ip"": ""198.51.100.4"", ""country_iso"": ""DE"", ""region_name"": ""Bavaria"",
""city"": ""Munich"", ""latitude"": 48.13, ""longitude"": 11.58, ""time_zone"": ""Europe/Berlin""
}"));
var withoutCoords = GeoLocationService.ParseIfConfigCo(JObject.Parse(@"{ ""country_iso"": ""DE"" }"));
Assert.Equal("48.13,11.58", withCoords.Loc);
Assert.Equal("DE", withCoords.Country);
Assert.Equal("Bavaria", withCoords.Region);
Assert.Equal("ifconfig.co", withCoords.Source);
Assert.Equal("", withoutCoords.Loc);
}
[Fact]
public void Describe_skips_the_parts_a_provider_did_not_return()
{
Assert.Equal("Stuttgart, BW, DE", new GeoLocationResult { City = "Stuttgart", Region = "BW", Country = "DE" }.Describe());
Assert.Equal("DE", new GeoLocationResult { Country = "DE", Region = "" }.Describe());
}
}
}
@@ -62,6 +62,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
Assert.Equal("ipinfo.io", evt.Data["source"]);
}
[Fact]
public void BuildGeoEvent_country_only_location_has_no_empty_parts_in_message()
{
var loc = new GeoLocationResult { Country = "DE", Source = "do-geo" };
var evt = StartupEnvironmentProbes.BuildGeoEvent(Config(), loc);
Assert.Equal("Device location: DE (via do-geo)", evt.Message);
Assert.Equal("DE", evt.Data["country"]);
Assert.Equal("do-geo", evt.Data["source"]);
}
// ================================================================= Outbound IP
[Fact]
@@ -98,6 +110,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
PrimaryError = "connection refused",
PrimaryRetryError = "DNS failure",
FallbackError = "timeout",
DoGeoError = "HTTP 403 (Forbidden)",
};
var evt = StartupEnvironmentProbes.BuildGeoFailureEvent(cfg, attempt);
@@ -108,6 +121,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
Assert.Equal("connection refused", evt.Data["primaryError"]);
Assert.Equal("DNS failure", evt.Data["primaryRetryError"]);
Assert.Equal("timeout", evt.Data["fallbackError"]);
Assert.Equal("HTTP 403 (Forbidden)", evt.Data["doGeoError"]);
Assert.Equal("do-geo", evt.Data["lastResortProvider"]);
}
[Fact]
@@ -119,6 +134,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
Assert.Equal("unknown", evt.Data["primaryError"]);
Assert.Equal("unknown", evt.Data["primaryRetryError"]);
Assert.Equal("unknown", evt.Data["fallbackError"]);
Assert.Equal("unknown", evt.Data["doGeoError"]);
}
// ================================================================= Timezone
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@@ -25,6 +26,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// </summary>
public string Ip { get; set; }
/// <summary>"City, Region, Country" without the parts a provider did not return.</summary>
public string Describe() =>
string.Join(", ", new[] { City, Region, Country }.Where(p => !string.IsNullOrEmpty(p)));
public Dictionary<string, object> ToDictionary()
{
return new Dictionary<string, object>
@@ -45,6 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
public string PrimaryError { get; set; }
public string PrimaryRetryError { get; set; }
public string FallbackError { get; set; }
public string DoGeoError { get; set; }
}
public static class GeoLocationService
@@ -52,6 +58,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2);
internal const string IpInfoUrl = "https://ipinfo.io/json";
internal const string IfConfigCoUrl = "https://ifconfig.co/json";
/// <summary>
/// Delivery Optimization's own geo endpoint. Country and egress IP only (no region, city,
/// coordinates or timezone), but it is part of the Windows Update / DO endpoint set that
/// Autopilot needs anyway, so it stays reachable in networks that block public geo services.
/// </summary>
internal const string DoGeoUrl = "https://geo.prod.do.dsp.mp.microsoft.com/geo";
// Static HttpClient to avoid socket exhaustion from repeated short-lived instances.
// HttpClient is designed to be reused across requests.
private static readonly HttpClient SharedHttpClient = CreateSharedHttpClient();
@@ -68,7 +84,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
var attempt = new GeoLocationAttemptResult();
// Try ipinfo.io first
var (result, error) = await TryIpInfo(logger);
var (result, error) = await TryProvider("ipinfo.io", IpInfoUrl, ParseIpInfo, logger);
if (result != null)
{
attempt.Location = result;
@@ -80,7 +96,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
logger?.Info($"GeoLocation: Retrying ipinfo.io after {RetryDelay.TotalSeconds}s...");
await Task.Delay(RetryDelay);
(result, error) = await TryIpInfo(logger);
(result, error) = await TryProvider("ipinfo.io", IpInfoUrl, ParseIpInfo, logger);
if (result != null)
{
attempt.Location = result;
@@ -89,7 +105,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
attempt.PrimaryRetryError = error;
// Fallback to ifconfig.co
(result, error) = await TryIfConfigCo(logger);
(result, error) = await TryProvider("ifconfig.co", IfConfigCoUrl, ParseIfConfigCo, logger);
if (result != null)
{
attempt.Location = result;
@@ -97,108 +113,111 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
}
attempt.FallbackError = error;
// Last resort: Delivery Optimization geo — country only, but better than no location
(result, error) = await TryProvider("do-geo", DoGeoUrl, ParseDoGeo, logger);
if (result != null)
{
attempt.Location = result;
return attempt;
}
attempt.DoGeoError = error;
logger?.Warning("GeoLocation: All providers failed, skipping location event");
return attempt;
}
private static async Task<(GeoLocationResult result, string error)> TryIpInfo(AgentLogger logger)
private static async Task<(GeoLocationResult result, string error)> TryProvider(
string name, string url, Func<JObject, GeoLocationResult> parse, AgentLogger logger)
{
try
{
logger?.Info("GeoLocation: Querying ipinfo.io...");
logger?.Info($"GeoLocation: Querying {name}...");
using (var httpResponse = await SharedHttpClient.GetAsync("https://ipinfo.io/json"))
using (var httpResponse = await SharedHttpClient.GetAsync(url))
{
if (!httpResponse.IsSuccessStatusCode)
{
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
logger?.Warning($"GeoLocation: {name} failed: {error}");
return (null, error);
}
var response = await httpResponse.Content.ReadAsStringAsync();
var json = JObject.Parse(response);
var result = new GeoLocationResult
var result = parse(JObject.Parse(response));
if (result == null)
{
Country = json.Value<string>("country"),
Region = json.Value<string>("region"),
City = json.Value<string>("city"),
Loc = json.Value<string>("loc"),
Timezone = json.Value<string>("timezone"),
Ip = json.Value<string>("ip"),
Source = "ipinfo"
};
var error = "No country in response";
logger?.Warning($"GeoLocation: {name} failed: {error}");
return (null, error);
}
logger?.Info($"GeoLocation: ipinfo.io returned {result.City}, {result.Region}, {result.Country}");
logger?.Info($"GeoLocation: {name} returned {result.Describe()}");
return (result, null);
}
}
catch (TaskCanceledException)
{
var error = "Timeout (5s)";
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
var error = $"Timeout ({RequestTimeout.TotalSeconds}s)";
logger?.Warning($"GeoLocation: {name} failed: {error}");
return (null, error);
}
catch (Exception ex)
{
var error = ex.Message;
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
logger?.Warning($"GeoLocation: {name} failed: {error}");
return (null, error);
}
}
private static async Task<(GeoLocationResult result, string error)> TryIfConfigCo(AgentLogger logger)
internal static GeoLocationResult ParseIpInfo(JObject json) =>
new GeoLocationResult
{
Country = json.Value<string>("country"),
Region = json.Value<string>("region"),
City = json.Value<string>("city"),
Loc = json.Value<string>("loc"),
Timezone = json.Value<string>("timezone"),
Ip = json.Value<string>("ip"),
Source = "ipinfo"
};
internal static GeoLocationResult ParseIfConfigCo(JObject json)
{
try
var latitude = json.Value<string>("latitude") ?? "";
var longitude = json.Value<string>("longitude") ?? "";
var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude)
? $"{latitude},{longitude}"
: "";
return new GeoLocationResult
{
logger?.Info("GeoLocation: Falling back to ifconfig.co...");
Country = json.Value<string>("country_iso"),
Region = json.Value<string>("region_name"),
City = json.Value<string>("city"),
Loc = loc,
Timezone = json.Value<string>("time_zone"),
Ip = json.Value<string>("ip"),
Source = "ifconfig.co"
};
}
using (var httpResponse = await SharedHttpClient.GetAsync("https://ifconfig.co/json"))
{
if (!httpResponse.IsSuccessStatusCode)
{
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
return (null, error);
}
/// <summary>
/// The DO geo response carries DO service configuration next to the geo fields; only
/// <c>CountryCode</c> and <c>ExternalIpAddress</c> are location data. Without a country
/// the response is worthless as a location, so it counts as a failure (null).
/// </summary>
internal static GeoLocationResult ParseDoGeo(JObject json)
{
var country = json.Value<string>("CountryCode");
if (string.IsNullOrEmpty(country))
return null;
var response = await httpResponse.Content.ReadAsStringAsync();
var json = JObject.Parse(response);
var latitude = json.Value<string>("latitude") ?? "";
var longitude = json.Value<string>("longitude") ?? "";
var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude)
? $"{latitude},{longitude}"
: "";
var result = new GeoLocationResult
{
Country = json.Value<string>("country_iso"),
Region = json.Value<string>("region_name"),
City = json.Value<string>("city"),
Loc = loc,
Timezone = json.Value<string>("time_zone"),
Ip = json.Value<string>("ip"),
Source = "ifconfig.co"
};
logger?.Info($"GeoLocation: ifconfig.co returned {result.City}, {result.Region}, {result.Country}");
return (result, null);
}
}
catch (TaskCanceledException)
return new GeoLocationResult
{
var error = "Timeout (5s)";
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
return (null, error);
}
catch (Exception ex)
{
var error = ex.Message;
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
return (null, error);
}
Country = country,
Ip = json.Value<string>("ExternalIpAddress"),
Source = "do-geo"
};
}
}
}
@@ -18,7 +18,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
/// </para>
/// <list type="bullet">
/// <item><b>device_location</b> (or <c>agent_trace</c> on failure) — IP-based geo via
/// <see cref="GeoLocationService"/>. Skipped when <c>EnableGeoLocation=false</c>.</item>
/// <see cref="GeoLocationService"/>. Skipped when <c>EnableGeoLocation=false</c>. The
/// last-resort provider (DO geo) yields a country-only location without timezone.</item>
/// <item><b>timezone_auto_set</b> — only when <c>EnableTimezoneAutoSet=true</c> AND the
/// geo lookup returned an IANA timezone. Uses <see cref="TimezoneService"/> (tzutil).</item>
/// <item><b>ntp_time_check</b> — NTP offset from <c>NtpServer</c> (default time.windows.com).
@@ -225,7 +226,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
Source = "StartupEnvironmentProbes",
Phase = EnrollmentPhase.Unknown,
Timestamp = DateTime.UtcNow,
Message = $"Device location: {location.City}, {location.Region}, {location.Country} (via {location.Source})",
Message = $"Device location: {location.Describe()} (via {location.Source})",
Data = location.ToDictionary(),
ImmediateUpload = true,
};
@@ -266,8 +267,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
{ "primaryError", attempt?.PrimaryError ?? "unknown" },
{ "primaryRetryError", attempt?.PrimaryRetryError ?? "unknown" },
{ "fallbackError", attempt?.FallbackError ?? "unknown" },
{ "doGeoError", attempt?.DoGeoError ?? "unknown" },
{ "primaryProvider", "ipinfo.io" },
{ "fallbackProvider", "ifconfig.co" },
{ "lastResortProvider", "do-geo" },
},
};
@@ -0,0 +1,276 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// D-285: the admin configuration row is changed only through the changed-columns write — fresh
/// read, mutation on a copy, only the columns whose value changed are laid onto the freshly read
/// row, conditional on its ETag. Pins the 2026-09-25 incident: a Global Settings page loaded before
/// an agent release saved one toggle and reverted the release's hashes with the full-model PUT.
/// </summary>
public class AdminConfigurationChangedFieldsTests
{
private const string ReleaseVersion = "2.0.1465";
private const string ReleaseZipSha = "9f0b5403da7e49202274fdf8f38675bc5cfea85f7d881e85d5fe4bd71308250e";
private const string ReleaseExeSha = "503dd02b3c3690acfe20c44dfce82c367ca1e56dcedaba3ecc91ff6ce893d726";
/// <summary>
/// A one-row table that behaves like storage: reads hand out copies with the current ETag,
/// a replace with a stale ETag fails 412, a create on an existing row fails 409.
/// </summary>
private sealed class FakeAdminTable
{
private int _version = 1;
public TableEntity? Current;
public int Writes;
public TableUpdateMode? LastMode;
/// <summary>Runs once, right before the first replace is checked — a concurrent writer.</summary>
public Action<FakeAdminTable>? BeforeFirstReplace;
public TableConfigRepository Sut { get; }
public FakeAdminTable(TableEntity? current)
{
Current = current;
var table = new Mock<TableClient>();
table.Setup(c => c.GetEntityAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns(() => Current == null
? throw new RequestFailedException(404, "Not Found", "ResourceNotFound", null)
: Task.FromResult(Response.FromValue(Copy(Current, _version), Mock.Of<Response>())));
table.Setup(c => c.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, ifMatch, mode, _) =>
{
if (BeforeFirstReplace != null)
{
var concurrent = BeforeFirstReplace;
BeforeFirstReplace = null;
concurrent(this);
}
if (ifMatch != Etag(_version))
throw new RequestFailedException(412, "Precondition Failed", "UpdateConditionNotSatisfied", null);
Current = Copy(e, _version);
_version++;
Writes++;
LastMode = mode;
return Task.FromResult(Mock.Of<Response>());
});
table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, CancellationToken>((e, _) =>
{
if (Current != null)
throw new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null);
Current = Copy(e, _version);
Writes++;
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(table.Object);
Sut = new TableConfigRepository(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
new Mock<IConfigBackupRepository>().Object,
NullLogger<TableConfigRepository>.Instance);
}
/// <summary>A write by someone else (the release pipeline's Merge), bumping the ETag.</summary>
public void ExternalMerge(string column, object value)
{
Current![column] = value;
_version++;
}
private static ETag Etag(int version) => new($"W/\"v{version}\"");
private static TableEntity Copy(TableEntity source, int version)
=> new(source.Where(kv => kv.Key != "odata.etag").ToDictionary(kv => kv.Key, kv => kv.Value)) { ETag = Etag(version) };
}
/// <summary>The stored row right after the agent release: the pipeline's hashes plus a column this build does not know.</summary>
private static TableEntity ReleasedRow()
{
var row = TableConfigRepository.ConvertToAdminTableEntity(new AdminConfiguration
{
UpdatedBy = "ga@operator.example",
GlobalRateLimitRequestsPerMinute = 100,
NvdApiKey = "stored-key",
LatestAgentV2Version = ReleaseVersion,
LatestAgentV2Sha256 = ReleaseZipSha,
LatestAgentV2ExeSha256 = ReleaseExeSha,
});
row["ColumnOfANewerBuild"] = "keep-me";
return row;
}
[Fact]
public async Task Stale_page_toggle_writes_only_that_field_and_keeps_the_release_hashes()
{
var table = new FakeAdminTable(ReleasedRow());
// The page was loaded before the release; it sends only what the operator changed.
var fields = JObject.Parse("{ \"enforceClientAppBinding\": true }");
Assert.Null(PatchAdminConfigurationFunction.CheckFields(fields));
var result = await table.Sut.UpdateAdminConfigurationAsync(
draft => PatchAdminConfigurationFunction.ApplyFields(draft, fields), "ga@operator.example");
Assert.Null(result.Error);
Assert.Equal(new[] { "EnforceClientAppBinding" }, result.ChangedColumns);
var row = table.Current!;
Assert.True(row.GetBoolean("EnforceClientAppBinding"));
Assert.Equal(ReleaseVersion, row.GetString("LatestAgentV2Version"));
Assert.Equal(ReleaseZipSha, row.GetString("LatestAgentV2Sha256"));
Assert.Equal(ReleaseExeSha, row.GetString("LatestAgentV2ExeSha256"));
Assert.Equal("keep-me", row.GetString("ColumnOfANewerBuild"));
Assert.Equal("stored-key", row.GetString("NvdApiKey"));
Assert.Equal(TableUpdateMode.Replace, table.LastMode);
Assert.Equal(1, table.Writes);
}
[Fact]
public async Task A_concurrent_pipeline_write_is_reread_and_kept()
{
var table = new FakeAdminTable(ReleasedRow())
{
// The release pipeline lands between the backend's read and its write.
BeforeFirstReplace = t => t.ExternalMerge("LatestAgentV2Version", "2.0.1466"),
};
var result = await table.Sut.UpdateAdminConfigurationAsync(
c => { c.GlobalRateLimitRequestsPerMinute = 250; return null; }, "System (test)");
Assert.Null(result.Error);
Assert.Equal("2.0.1466", table.Current!.GetString("LatestAgentV2Version"));
Assert.Equal(250, table.Current.GetInt32("GlobalRateLimitRequestsPerMinute"));
Assert.Equal(1, table.Writes);
}
[Fact]
public async Task A_mutation_that_changes_nothing_writes_nothing()
{
var table = new FakeAdminTable(ReleasedRow());
var result = await table.Sut.UpdateAdminConfigurationAsync(
c => { c.GlobalRateLimitRequestsPerMinute = 100; return null; }, "ga@operator.example");
Assert.Null(result.Error);
Assert.Empty(result.ChangedColumns);
Assert.Equal(0, table.Writes);
}
[Fact]
public async Task A_rejected_mutation_writes_nothing()
{
var table = new FakeAdminTable(ReleasedRow());
var result = await table.Sut.UpdateAdminConfigurationAsync(
c => { c.GlobalRateLimitRequestsPerMinute = 999; return "nope"; }, "ga@operator.example");
Assert.Equal("nope", result.Error);
Assert.Equal(0, table.Writes);
Assert.Equal(100, table.Current!.GetInt32("GlobalRateLimitRequestsPerMinute"));
}
[Fact]
public async Task A_null_value_drops_its_column()
{
var table = new FakeAdminTable(ReleasedRow());
var result = await table.Sut.UpdateAdminConfigurationAsync(
c => { c.NvdApiKey = null!; return null; }, "ga@operator.example"); // what a JSON null in a patch does
Assert.Equal(new[] { "NvdApiKey" }, result.ChangedColumns);
Assert.False(table.Current!.ContainsKey("NvdApiKey"));
Assert.Equal(ReleaseExeSha, table.Current.GetString("LatestAgentV2ExeSha256"));
}
[Fact]
public async Task Without_a_row_the_first_change_creates_it()
{
var table = new FakeAdminTable(current: null);
var result = await table.Sut.UpdateAdminConfigurationAsync(
c => { c.EnforceClientAppBinding = true; return null; }, "ga@operator.example");
Assert.Null(result.Error);
Assert.True(table.Current!.GetBoolean("EnforceClientAppBinding"));
Assert.Equal(1, table.Writes);
}
[Fact]
public async Task Default_creation_never_replaces_an_existing_row()
{
var table = new FakeAdminTable(ReleasedRow());
var created = await table.Sut.CreateAdminConfigurationIfMissingAsync(AdminConfiguration.CreateDefault());
Assert.False(created);
Assert.Equal(ReleaseVersion, table.Current!.GetString("LatestAgentV2Version"));
}
[Theory]
[InlineData("latestAgentV2Version")]
[InlineData("LatestAgentV2Sha256")]
[InlineData("latestAgentV2ExeSha256")]
[InlineData("latestBootstrapV2ScriptVersion")]
[InlineData("updatedBy")]
[InlineData("lastUpdated")]
[InlineData("planTierDefinitionsJson")]
[InlineData("msrcLastSyncUtc")]
public void The_gate_rejects_pipeline_stamp_and_flow_owned_fields(string field)
{
var error = PatchAdminConfigurationFunction.CheckFields(new JObject { [field] = "x" });
Assert.NotNull(error);
Assert.Contains("not writable", error);
}
[Fact]
public void The_gate_rejects_unknown_fields_and_redacted_placeholders()
{
Assert.Contains("Unknown field", PatchAdminConfigurationFunction.CheckFields(new JObject { ["noSuchField"] = 1 }));
Assert.Contains("placeholder", PatchAdminConfigurationFunction.CheckFields(
new JObject { ["nvdApiKey"] = Constants.RedactedSecretPlaceholder }));
}
[Fact]
public void Patchable_fields_are_the_persisted_settings()
{
var patchable = PatchAdminConfigurationFunction.PatchableFields;
Assert.Contains("EnforceClientAppBinding", patchable);
Assert.Contains("McpClientRegistrationEnabled", patchable);
Assert.Contains("McpAccessPolicy", patchable);
Assert.Contains("NvdApiKey", patchable);
Assert.Contains("OpsNotificationChannelsJson", patchable);
Assert.DoesNotContain("LatestAgentV2ExeSha256", patchable);
Assert.DoesNotContain("PartitionKey", patchable);
Assert.DoesNotContain("UpdatedBy", patchable);
}
[Fact]
public void Applying_validates_the_result_without_echoing_values()
{
var draft = AdminConfiguration.CreateDefault();
Assert.Contains("at least 1", PatchAdminConfigurationFunction.ApplyFields(
draft, JObject.Parse("{ \"globalRateLimitRequestsPerMinute\": 0 }")));
var typeError = PatchAdminConfigurationFunction.ApplyFields(
AdminConfiguration.CreateDefault(), JObject.Parse("{ \"collectorIdleTimeoutMinutes\": \"https://secret.example/sas?sig=abc\" }"));
Assert.NotNull(typeError);
Assert.DoesNotContain("secret.example", typeError);
}
}
@@ -14,7 +14,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// for the device path and UserRateLimitMiddleware for the user path). These tests pin the
/// removal of the former background sync job:
/// <list type="bullet">
/// <item>Saving the admin config must NOT enumerate or write ANY tenant configuration.</item>
/// <item>Changing the admin config must NOT enumerate or write ANY tenant configuration.</item>
/// </list>
/// <para>
/// Historical context: the sync used to copy <c>GlobalRateLimitRequestsPerMinute</c> into every
@@ -28,26 +28,33 @@ namespace AutopilotMonitor.Functions.Tests;
public class AdminConfigurationRateLimitSyncTests
{
[Fact]
public async Task SaveConfigurationAsync_DoesNotEnumerateOrWriteTenantConfigs()
public async Task UpdateAsync_DoesNotEnumerateOrWriteTenantConfigs()
{
var repo = new Mock<IConfigRepository>(MockBehavior.Strict);
repo.Setup(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()))
.ReturnsAsync(true);
repo.Setup(r => r.UpdateAdminConfigurationAsync(
It.IsAny<Func<AdminConfiguration, string?>>(), It.IsAny<string>(), It.IsAny<string?>()))
.ReturnsAsync(new AdminConfigurationUpdateResult
{
Before = new AdminConfiguration(),
After = new AdminConfiguration(),
ChangedColumns = new[] { "GlobalRateLimitRequestsPerMinute", "UserRateLimitRequestsPerMinute" },
});
var sut = new AdminConfigurationService(
repo.Object,
NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
await sut.SaveConfigurationAsync(new AdminConfiguration
await sut.UpdateAsync(c =>
{
UpdatedBy = "global-admin@contoso.com",
GlobalRateLimitRequestsPerMinute = 200,
UserRateLimitRequestsPerMinute = 240,
});
c.GlobalRateLimitRequestsPerMinute = 200;
c.UserRateLimitRequestsPerMinute = 240;
return null;
}, "global-admin@contoso.com");
// The admin config row is written...
repo.Verify(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()), Times.Once);
repo.Verify(r => r.UpdateAdminConfigurationAsync(
It.IsAny<Func<AdminConfiguration, string?>>(), "global-admin@contoso.com", It.IsAny<string?>()), Times.Once);
// ...but NO tenant configuration is enumerated or mutated (sync removed).
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
@@ -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));
}
}
@@ -56,6 +56,13 @@ public class ConfigBackupHookTests
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
Table.Setup(c => c.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
{
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
@@ -225,15 +232,11 @@ public class ConfigBackupHookTests
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
.Returns(Task.CompletedTask);
var incoming = new AdminConfiguration
{
UpdatedBy = "ga@operator.example",
GlobalRateLimitRequestsPerMinute = 200,
};
var result = await harness.Sut.UpdateAdminConfigurationAsync(
c => { c.GlobalRateLimitRequestsPerMinute = 200; return null; }, "ga@operator.example");
var saved = await harness.Sut.SaveAdminConfigurationAsync(incoming);
Assert.True(saved);
Assert.Null(result.Error);
Assert.Equal(new[] { "GlobalRateLimitRequestsPerMinute" }, result.ChangedColumns);
Assert.NotNull(snapshot);
Assert.Equal("GlobalConfig", snapshot!.PartitionKey);
Assert.Equal("admin-config", snapshot.Source);
@@ -115,4 +115,21 @@ public class GeographicDrilldownFilterTests
Assert.Equal("Saxony", fal.Region);
Assert.Equal("Falkenstein", fal.City);
}
[Theory]
[InlineData("city")]
[InlineData("region")]
[InlineData("country")]
public void CountryOnlySession_KeyIsTheCountryAndDrillsDownToItself(string groupBy)
{
// The agent's last-resort geo provider returns the country only.
var countryOnly = Geo("de-only", "DE", "", "");
var sessions = new List<SessionSummary> { countryOnly, Geo("de-bav", "DE", "Bavaria", "Munich") };
var key = GetGeographicMetricsFunction.GetLocationKey(countryOnly, groupBy);
var drilled = GetGeographicLocationSessionsFunction.FilterSessionsByLocation(sessions, key, groupBy);
Assert.Equal("DE", key); // never ", DE" or ", , DE"
Assert.Contains(drilled, s => s.SessionId == "de-only");
}
}
@@ -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,
@@ -61,7 +61,7 @@ public class TypedRequestGuardTests
private static readonly Dictionary<string, int> NewtonsoftBaseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = 1,
["Functions/Config/UpdateAdminConfigurationFunction.cs"] = 1,
["Functions/Config/PatchAdminConfigurationFunction.cs"] = 1,
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = 1,
["Functions/Rules/AnalyzeRulesFunction.cs"] = 2,
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = 1,
@@ -157,13 +157,21 @@ public class TypedRequestGuardTests
var isObjectMap = slot.IsGenericType && slot.GetGenericTypeDefinition() == typeof(Dictionary<,>) && slot.GetGenericArguments()[1] == typeof(object);
if (slot == typeof(object))
offenders.Add($"{t.Name}.{p.Name}: object slot (TypeScript gets unknown)");
else if (isObjectMap && !(t == typeof(PatchTenantConfigurationFieldsRequest) && p.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields)))
offenders.Add($"{t.Name}.{p.Name}: untyped map (only the tenant-config field patch carries one by design)");
else if (isObjectMap && !IsFieldPatchMap(t, p))
offenders.Add($"{t.Name}.{p.Name}: untyped map (only the config field patches carry one by design)");
}
}
Assert.True(offenders.Count == 0, "Request DTO shape violations:\n " + string.Join("\n ", offenders));
}
/// <summary>
/// The two field patches (tenant and admin configuration) carry a { field: value } map on purpose:
/// each value takes its field's own JSON type and the server gates the keys against the model.
/// </summary>
private static bool IsFieldPatchMap(Type type, PropertyInfo property)
=> (type == typeof(PatchTenantConfigurationFieldsRequest) && property.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields))
|| (type == typeof(PatchAdminConfigurationRequest) && property.Name == nameof(PatchAdminConfigurationRequest.Fields));
private static IEnumerable<(string Relative, string Text)> HandlerSources()
{
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
@@ -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>();
@@ -327,23 +327,126 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task<bool> SaveAdminConfigurationAsync(AdminConfiguration config)
{
await TrySnapshotBeforeSaveAsync(
_adminConfigTableClient, "GlobalConfig", "config",
ConvertFromAdminTableEntity, config, AdminBackupNoiseProperties,
config.UpdatedBy, "admin-config", reason: null);
internal const int AdminUpdateMaxAttempts = 3;
/// <summary>Server stamps: written on every accepted change, never counted as one.</summary>
private static readonly HashSet<string> AdminStampColumns = new(StringComparer.Ordinal)
{
"PartitionKey", "RowKey", "LastUpdated", "UpdatedBy",
};
/// <summary>
/// The one write path of the admin row (D-285). Each attempt reads the raw row fresh with
/// its ETag and materializes it twice — <c>before</c> and the <c>draft</c> the mutation edits —
/// so the copy is exact for every persisted column. The changed columns are computed from
/// the two converter projections and laid onto the freshly read raw entity (a null value
/// drops the column); the conditional replace then writes exactly those plus the stamps.
/// Every other column keeps its stored value: the release pipeline's <c>LatestAgentV2*</c>,
/// columns of a newer build, retired columns. The former full-model replace wrote every
/// column from whatever copy the caller held, so a portal page loaded before an agent
/// release reverted the release's hashes on save (2026-09-25).
/// </summary>
public async Task<AdminConfigurationUpdateResult> UpdateAdminConfigurationAsync(
Func<AdminConfiguration, string?> mutate, string updatedBy, string? source = null)
{
if (mutate == null) throw new ArgumentNullException(nameof(mutate));
for (var attempt = 1; attempt <= AdminUpdateMaxAttempts; attempt++)
{
TableEntity? stored = null;
try
{
stored = (await _adminConfigTableClient.GetEntityAsync<TableEntity>("GlobalConfig", "config")).Value;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// No row yet: the first accepted change creates it from the defaults.
}
var before = stored != null ? ConvertFromAdminTableEntity(stored) : AdminConfiguration.CreateDefault();
var draft = stored != null ? ConvertFromAdminTableEntity(stored) : AdminConfiguration.CreateDefault();
var error = mutate(draft);
if (error != null)
return new AdminConfigurationUpdateResult { Error = error, Before = before, After = before };
var beforeColumns = ConvertToAdminTableEntity(before);
var draftColumns = ConvertToAdminTableEntity(draft);
var changed = ChangedAdminColumns(beforeColumns, draftColumns);
if (changed.Count == 0)
return new AdminConfigurationUpdateResult { Before = before, After = before };
draft.UpdatedBy = updatedBy;
draft.LastUpdated = DateTime.UtcNow;
await TrySnapshotBeforeSaveAsync(
_adminConfigTableClient, "GlobalConfig", "config",
ConvertFromAdminTableEntity, draft, AdminBackupNoiseProperties,
updatedBy, source ?? "admin-config", reason: null);
try
{
if (stored == null)
{
await _adminConfigTableClient.AddEntityAsync(ConvertToAdminTableEntity(draft));
}
else
{
foreach (var column in changed)
{
var value = draftColumns[column];
if (value == null) stored.Remove(column);
else stored[column] = value;
}
stored["LastUpdated"] = draft.LastUpdated;
stored["UpdatedBy"] = draft.UpdatedBy;
await _adminConfigTableClient.UpdateEntityAsync(stored, stored.ETag, TableUpdateMode.Replace);
}
return new AdminConfigurationUpdateResult { Before = before, After = draft, ChangedColumns = changed };
}
catch (RequestFailedException ex) when (ex.Status == 412 || ex.Status == 409)
{
// Someone wrote (or created) the row since this attempt's read: re-read and
// re-apply the mutation onto their state instead of overwriting it.
_logger.LogInformation(
"Admin configuration changed concurrently (attempt {Attempt}/{Max}) — re-reading",
attempt, AdminUpdateMaxAttempts);
await Task.Delay(TimeSpan.FromMilliseconds(150));
}
}
throw new InvalidOperationException(
$"Admin configuration update lost {AdminUpdateMaxAttempts} conditional-write races — retry shortly.");
}
/// <summary>
/// Columns whose projected value differs between the two converter outputs. Both sides come
/// from <see cref="ConvertToAdminTableEntity"/>, so value types match and plain equality holds.
/// </summary>
internal static List<string> ChangedAdminColumns(TableEntity before, TableEntity after)
{
var changed = new List<string>();
foreach (var column in after.Keys.Union(before.Keys, StringComparer.Ordinal))
{
if (AdminStampColumns.Contains(column)) continue;
before.TryGetValue(column, out var b);
after.TryGetValue(column, out var a);
if (!Equals(b, a)) changed.Add(column);
}
return changed;
}
public async Task<bool> CreateAdminConfigurationIfMissingAsync(AdminConfiguration config)
{
try
{
var entity = ConvertToAdminTableEntity(config);
await _adminConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
await _adminConfigTableClient.AddEntityAsync(ConvertToAdminTableEntity(config));
return true;
}
catch (Exception ex)
catch (RequestFailedException ex) when (ex.Status == 409)
{
_logger.LogError(ex, "Error saving admin configuration");
return false;
return false; // A row exists — never replace it with defaults.
}
}
@@ -670,6 +773,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 +888,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",
@@ -929,10 +1034,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled },
{ "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty },
{ "OpsNotificationChannelsJson", config.OpsNotificationChannelsJson ?? string.Empty },
// Per-line agent binary integrity (written by build scripts via Merge).
// Per-line agent binary integrity: written by the release pipeline via Merge.
// The backend never changes them (the PATCH gate rejects them), so the
// changed-columns write (UpdateAdminConfigurationAsync) never touches them.
// V2 is the only wired line; future V3 = add field set here. Retired columns
// (V1-suffix and the even older unsuffixed "LatestAgent*") are never written,
// so the next Save evicts them implicitly on overwrite.
// (V1-suffix and the even older unsuffixed "LatestAgent*") are not projected.
{ "AllowAgentDowngrade", config.AllowAgentDowngrade },
{ "LatestAgentV2Version", config.LatestAgentV2Version ?? string.Empty },
{ "LatestAgentV2Sha256", config.LatestAgentV2Sha256 ?? string.Empty },
@@ -950,6 +1056,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 +1127,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
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Functions.Config
{
/// <summary>
/// PATCH global/config — the portal's only write to the platform configuration (D-285). The body
/// names just the fields the operator changed; the repository re-reads the row, applies them and
/// writes only the columns whose value actually changed. It replaced a full-model PUT that wrote
/// every column from the page's copy, so a page loaded before an agent release reverted the
/// release's hashes on save (2026-09-25).
/// </summary>
public class PatchAdminConfigurationFunction
{
private const int MaxBodyBytes = 1_048_576;
private readonly ILogger<PatchAdminConfigurationFunction> _logger;
private readonly AdminConfigurationService _adminConfigService;
private readonly IMaintenanceRepository _maintenanceRepo;
public PatchAdminConfigurationFunction(
ILogger<PatchAdminConfigurationFunction> logger,
AdminConfigurationService adminConfigService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_adminConfigService = adminConfigService;
_maintenanceRepo = maintenanceRepo;
}
/// <summary>
/// Fields no patch may write: the server stamps, the columns only the agent release pipeline
/// writes (build-agent.yml / publish-scripts.yml), and the ones owned by their own flow
/// (plan-tier endpoint, vulnerability-sync bookkeeping).
/// </summary>
internal static readonly HashSet<string> DeniedFields = new(StringComparer.OrdinalIgnoreCase)
{
"LastUpdated", "UpdatedBy",
"LatestAgentV2Version", "LatestAgentV2Sha256", "LatestAgentV2ExeSha256", "LatestBootstrapV2ScriptVersion",
"PlanTierDefinitionsJson",
"VulnerabilityDataLastSyncUtc", "MsrcLastSyncUtc", "NvdCacheLastRefreshUtc", "EpssLastSyncUtc",
};
/// <summary>
/// Every persisted, writable AdminConfiguration property except <see cref="DeniedFields"/>.
/// Derived from the entity converter, so a new column is patchable the moment it is persisted
/// and a model property that is never stored cannot be "saved" into nothing.
/// </summary>
internal static readonly IReadOnlyCollection<string> PatchableFields = BuildPatchableFields();
private static HashSet<string> BuildPatchableFields()
{
var writable = new HashSet<string>(
typeof(AdminConfiguration)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0)
.Select(p => p.Name),
StringComparer.OrdinalIgnoreCase);
return new HashSet<string>(
TableConfigRepository.ConvertToAdminTableEntity(AdminConfiguration.CreateDefault()).Keys
.Where(c => c != "PartitionKey" && c != "RowKey" && writable.Contains(c) && !DeniedFields.Contains(c)),
StringComparer.OrdinalIgnoreCase);
}
[Function("PatchAdminConfiguration")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/config")] HttpRequestData req)
{
try
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
string userIdentifier = TenantHelper.GetUserIdentifier(req);
// Newtonsoft on purpose: the field map is applied with JsonConvert.PopulateObject, like the
// tenant field patch (TypedRequestGuardTests baseline).
var read = await req.ReadNewtonsoftAsync<PatchAdminConfigurationRequest>(MaxBodyBytes);
if (read.Error != null) return read.Error;
var request = read.Value!;
if (request.Fields == null || request.Fields.Count == 0)
{
return await req.BadRequestAsync(
"Body must be { \"fields\": { <fieldName>: <value>, ... } } with at least one field.");
}
var fields = JObject.FromObject(request.Fields);
var gateError = CheckFields(fields);
if (gateError != null) return await req.BadRequestAsync(gateError);
var result = await _adminConfigService.UpdateAsync(
draft => ApplyFields(draft, fields), userIdentifier, "portal");
if (result.Error != null) return await req.BadRequestAsync(result.Error);
_logger.LogInformation(
"PatchAdminConfiguration by {User}: {Count} field(s) sent, changed: {Columns}",
userIdentifier, fields.Count, string.Join(", ", result.ChangedColumns));
if (result.ChangedColumns.Count > 0)
{
await _maintenanceRepo.LogAuditEntryAsync(
Constants.AuditGlobalTenantId,
"UPDATE",
"AdminConfiguration",
"GlobalConfig",
userIdentifier,
ConfigDiffHelper.GetChanges(result.Before, result.After));
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new UpdateAdminConfigurationResponse
{
Success = true,
Message = result.ChangedColumns.Count > 0
? "Admin configuration updated successfully"
: "No changes — nothing was written",
Config = result.After,
});
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "PatchAdminConfiguration");
}
}
/// <summary>
/// The field gate, before any storage work: every key must be a patchable field, named in the
/// error when it is not so the caller can correct itself.
/// </summary>
internal static string? CheckFields(JObject fields)
{
foreach (var prop in fields.Properties())
{
if (DeniedFields.Contains(prop.Name))
{
return $"Field \"{prop.Name}\" is not writable here (server stamp, owned by the agent release " +
"pipeline, or written by its own flow).";
}
if (!PatchableFields.Contains(prop.Name))
return $"Unknown field \"{prop.Name}\".";
// The ops channel list carries per-channel redacted destinations that are restored below;
// anywhere else the placeholder would overwrite a real secret with the sentinel.
if (!prop.Name.Equals(nameof(AdminConfiguration.OpsNotificationChannelsJson), StringComparison.OrdinalIgnoreCase)
&& prop.Value is JValue { Type: JTokenType.String, Value: string text }
&& text.Contains(Constants.RedactedSecretPlaceholder, StringComparison.Ordinal))
{
return $"Field \"{prop.Name}\" carries the \"{Constants.RedactedSecretPlaceholder}\" placeholder — " +
"a redacted read must never be written back.";
}
}
return null;
}
/// <summary>
/// Applies the gated fields onto the repository's fresh draft and validates the result. JSON
/// null clears a nullable field; camelCase keys bind case-insensitively to the model.
/// </summary>
internal static string? ApplyFields(AdminConfiguration draft, JObject fields)
{
var storedChannels = draft.OpsNotificationChannelsJson;
try
{
JsonConvert.PopulateObject(fields.ToString(), draft, new JsonSerializerSettings
{
MissingMemberHandling = MissingMemberHandling.Error,
});
}
catch (JsonException ex)
{
// Never ex.Message: Newtonsoft quotes the offending VALUE, which may be a secret.
var path = (ex as JsonSerializationException)?.Path ?? (ex as JsonReaderException)?.Path;
return string.IsNullOrEmpty(path)
? "A value has the wrong JSON type for its field."
: $"The value for \"{path}\" has the wrong JSON type.";
}
// Rate limits must be positive: a zero/negative value would throttle every request
// (RateLimitService clamps as a last resort, but reject at the edge for a clear error).
var rateLimitError =
draft.GlobalRateLimitRequestsPerMinute < 1 ? "Global Device API Rate Limit" :
draft.UserRateLimitRequestsPerMinute < 1 ? "MCP & Integrations API Rate Limit" :
draft.PortalUserRateLimitRequestsPerMinute < 1 ? "Portal User API Rate Limit" :
draft.GlobalAdminRateLimitRequestsPerMinute < 1 ? "Global Admin API Rate Limit" :
null;
if (rateLimitError != null)
return $"{rateLimitError} must be at least 1 request per minute.";
if (fields.Properties().Any(p => p.Name.Equals(
nameof(AdminConfiguration.OpsNotificationChannelsJson), StringComparison.OrdinalIgnoreCase)))
{
// A GlobalReader is served destinations as the ***REDACTED*** sentinel; persisting it as a
// chat ID or webhook URL would silently break every alert, so restore per channel id.
draft.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
draft.OpsNotificationChannelsJson, storedChannels);
// Same structural gate the tenant channel list passes. No GA gate on Telegram here:
// this endpoint is GlobalAdminOnly by policy.
var channelsError = TenantConfigValidation.ValidateNotificationChannels(draft.OpsNotificationChannelsJson);
if (channelsError != null)
return $"Invalid ops notification channels: {channelsError}";
}
return null;
}
}
}
@@ -602,9 +602,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
foreach (var tier in body.Tiers)
tier.Name = tier.Name.ToLowerInvariant();
var config = await _adminConfigService.GetConfigurationAsync();
config.PlanTierDefinitionsJson = JsonSerializer.Serialize(body.Tiers);
await _adminConfigService.SaveConfigurationAsync(config);
var tiersJson = JsonSerializer.Serialize(body.Tiers);
await _adminConfigService.UpdateAsync(
c => { c.PlanTierDefinitionsJson = tiersJson; return null; },
TenantHelper.GetUserIdentifier(req), "plan-tiers");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new PlanTierDefinitionsResponse { Tiers = body.Tiers, Catalog = BuildUsagePlanCatalog() });
@@ -1,118 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using System.Collections.Generic;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Config
{
public class UpdateAdminConfigurationFunction
{
private readonly ILogger<UpdateAdminConfigurationFunction> _logger;
private readonly AdminConfigurationService _adminConfigService;
private readonly IMaintenanceRepository _maintenanceRepo;
public UpdateAdminConfigurationFunction(
ILogger<UpdateAdminConfigurationFunction> logger,
AdminConfigurationService adminConfigService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_adminConfigService = adminConfigService;
_maintenanceRepo = maintenanceRepo;
}
[Function("UpdateAdminConfiguration")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "global/config")] HttpRequestData req)
{
try
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
string userIdentifier = TenantHelper.GetUserIdentifier(req);
_logger.LogInformation($"UpdateAdminConfiguration by Global Admin user {userIdentifier}");
// Parse request body
// Newtonsoft on purpose: same configuration stack as the tenant PUT — TypedRequestGuardTests baseline.
var read = await req.ReadNewtonsoftAsync<AdminConfiguration>(1_048_576);
if (read.Error != null) return read.Error;
var config = read.Value!;
// Rate limits must be positive: a zero/negative value would throttle every request
// (RateLimitService clamps as a last resort, but reject at the edge for a clear error).
var rateLimitError =
config.GlobalRateLimitRequestsPerMinute < 1 ? "Global Device API Rate Limit" :
config.UserRateLimitRequestsPerMinute < 1 ? "MCP & Integrations API Rate Limit" :
config.PortalUserRateLimitRequestsPerMinute < 1 ? "Portal User API Rate Limit" :
config.GlobalAdminRateLimitRequestsPerMinute < 1 ? "Global Admin API Rate Limit" :
null;
if (rateLimitError != null)
{
return await req.BadRequestAsync($"{rateLimitError} must be at least 1 request per minute.");
}
// Set the actual user identifier for audit logging
config.UpdatedBy = userIdentifier;
// Ensure PartitionKey and RowKey are correct
config.PartitionKey = "GlobalConfig";
config.RowKey = "config";
// Load existing config for diff before saving
var existingConfig = await _adminConfigService.GetConfigurationAsync();
// Ops channels: a GlobalReader is served a redacted copy (destinations replaced
// with the ***REDACTED*** sentinel). Only a GA can reach this endpoint, so this is
// defense-in-depth — but persisting the literal sentinel as a chat ID or webhook
// URL would silently break every alert, so restore per channel id first.
config.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
config.OpsNotificationChannelsJson, existingConfig.OpsNotificationChannelsJson);
// Same structural gate the tenant channel list passes (ids, provider types,
// SSRF-checked URLs / Telegram chat IDs, header and signing-secret shape). No GA
// gate on Telegram here: this endpoint is GlobalAdminOnly by policy.
var channelsError = TenantConfigValidation.ValidateNotificationChannels(config.OpsNotificationChannelsJson);
if (channelsError != null)
{
return await req.BadRequestAsync($"Invalid ops notification channels: {channelsError}");
}
var changes = ConfigDiffHelper.GetChanges(existingConfig, config);
// Save configuration
await _adminConfigService.SaveConfigurationAsync(config);
await _maintenanceRepo.LogAuditEntryAsync(
AutopilotMonitor.Shared.Constants.AuditGlobalTenantId,
"UPDATE",
"AdminConfiguration",
"GlobalConfig",
userIdentifier,
changes.Count > 0 ? changes : null
);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new UpdateAdminConfigurationResponse
{
Success = true,
Message = "Admin configuration updated successfully",
Config = config
});
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "UpdateAdminConfiguration");
}
}
}
}
@@ -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);
@@ -301,11 +301,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return groupBy.ToLower() switch
{
"country" => session.GeoCountry,
"region" => $"{session.GeoRegion}, {session.GeoCountry}",
_ => !string.IsNullOrEmpty(session.GeoCity)
? $"{session.GeoCity}, {session.GeoRegion}, {session.GeoCountry}"
: $"{session.GeoRegion}, {session.GeoCountry}"
"region" => JoinLocationParts(session.GeoRegion, session.GeoCountry),
_ => JoinLocationParts(session.GeoCity, session.GeoRegion, session.GeoCountry)
};
}
// Providers can omit region/city (the agent's last-resort provider returns the country
// only), so empty parts are skipped instead of producing keys like ", DE".
private static string JoinLocationParts(params string[] parts) =>
string.Join(", ", parts.Where(p => !string.IsNullOrEmpty(p)));
}
}
@@ -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;
@@ -49,12 +49,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
if (string.IsNullOrEmpty(msrc.Error))
{
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
{
config.MsrcLastSyncUtc = DateTime.UtcNow.ToString("o");
await _adminConfigService.SaveConfigurationAsync(config);
}
var syncedAt = DateTime.UtcNow.ToString("o");
await _adminConfigService.UpdateAsync(
c => { c.MsrcLastSyncUtc = syncedAt; return null; },
"System (MSRC sync)", "vuln-sync");
}
var durationMs = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds;
@@ -97,12 +97,10 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
}
// Update sync timestamp
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
{
config.VulnerabilityDataLastSyncUtc = DateTime.UtcNow.ToString("o");
await _adminConfigService.SaveConfigurationAsync(config);
}
var syncedAt = DateTime.UtcNow.ToString("o");
await _adminConfigService.UpdateAsync(
c => { c.VulnerabilityDataLastSyncUtc = syncedAt; return null; },
"System (vulnerability sync)", "vuln-sync");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new TriggerVulnerabilityDataSyncResponse
@@ -117,22 +117,14 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
// "Last KEV/MSRC Sync" accurately. (NVD/EPSS stamps are written by the service.)
try
{
var config = await _adminConfigService.GetConfigurationAsync();
if (config != null)
if (kevSyncedAt != null || msrcSyncedAt != null)
{
bool dirty = false;
if (kevSyncedAt != null)
await _adminConfigService.UpdateAsync(c =>
{
config.VulnerabilityDataLastSyncUtc = kevSyncedAt;
dirty = true;
}
if (msrcSyncedAt != null)
{
config.MsrcLastSyncUtc = msrcSyncedAt;
dirty = true;
}
if (dirty)
await _adminConfigService.SaveConfigurationAsync(config);
if (kevSyncedAt != null) c.VulnerabilityDataLastSyncUtc = kevSyncedAt;
if (msrcSyncedAt != null) c.MsrcLastSyncUtc = msrcSyncedAt;
return null;
}, "System (vulnerability sync)", "vuln-sync");
}
}
catch (Exception ex)
@@ -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),
@@ -472,8 +479,8 @@ public static class EndpointAccessPolicyCatalog
// ?tenantId= path — never the no-tenantId aggregate (which the delegated-rescue cannot satisfy).
new("GET", "global/metrics/summary", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/config", EndpointPolicy.GlobalReadOrAdmin),
new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/config", EndpointPolicy.GlobalAdminOnly),
// Changed fields only (D-285); the former full-model PUT/POST is gone.
new("PATCH", "global/config", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/config/test-ops-channel", EndpointPolicy.GlobalAdminOnly),
new("GET", "config/all", EndpointPolicy.GlobalReadOrDelegatedSubset),
// Transactional field-level config writes + snapshot list/revert (MCP + web Settings).
@@ -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;
}
}
@@ -78,8 +78,8 @@ namespace AutopilotMonitor.Functions.Services
try
{
// Save the default configuration via repository
await _configRepo.SaveAdminConfigurationAsync(defaultConfig);
// Create-only: a concurrent first write (or a row this read missed) is never replaced.
await _configRepo.CreateAdminConfigurationIfMissingAsync(defaultConfig);
_cache.Set(CacheKey, defaultConfig, CacheDuration);
@@ -191,38 +191,24 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Saves global admin configuration and syncs rate limit to all tenant configurations
/// Changes the admin configuration through the repository's changed-columns write (D-285):
/// fresh read, <paramref name="mutate"/> on a copy, only the columns it changed are written.
/// Never pass a configuration object around to save it — a cached or page-loaded copy is
/// exactly what used to revert other writers' changes. Invalidates this instance's cache
/// after a write; other instances catch up within the 5-minute TTL.
/// </summary>
public async Task SaveConfigurationAsync(AdminConfiguration config)
public virtual async Task<AdminConfigurationUpdateResult> UpdateAsync(
Func<AdminConfiguration, string?> mutate, string updatedBy, string? source = null)
{
if (config == null)
var result = await _configRepo.UpdateAdminConfigurationAsync(mutate, updatedBy, source);
if (result.Error == null && result.ChangedColumns.Count > 0)
{
throw new ArgumentException("Configuration is required");
}
try
{
config.LastUpdated = DateTime.UtcNow;
await _configRepo.SaveAdminConfigurationAsync(config);
// Invalidate cache
_cache.Remove(CacheKey);
_logger.LogInformation($"Admin configuration saved by {config.UpdatedBy}");
// NOTE: Rate limits are no longer mirrored into every tenant row. The effective
// per-tenant limit is computed at read time as `tenantOverride ?? global`
// (see SecurityValidator for the device path and UserRateLimitMiddleware for the
// user path). This removes the former background sync job — and the clobbering
// foot-gun where a per-tenant edit to the base field was overwritten on the next
// global save.
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving admin configuration");
throw;
_logger.LogInformation(
"Admin configuration changed by {UpdatedBy}: {Columns}",
updatedBy, string.Join(", ", result.ChangedColumns));
}
return result;
}
/// <summary>
@@ -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).
@@ -281,7 +281,7 @@ namespace AutopilotMonitor.Functions.Services
// Tenants come from TenantConfiguration (1 row per tenant — cheap).
// PLUS the synthetic global-tenant partition (Constants.AuditGlobalTenantId)
// where platform-action audits are written from TenantOffboardFunction,
// VersionBlockFunction, UpdateAdminConfigurationFunction, … TenantConfiguration
// VersionBlockFunction, PatchAdminConfigurationFunction, … TenantConfiguration
// has no row for it because it's a virtual partition, so it'd be
// silently skipped without this explicit add.
var tenantIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
@@ -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>
@@ -314,10 +314,8 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
{
try
{
var config = await _adminConfigService.GetConfigurationAsync();
if (config == null) return;
set(config);
await _adminConfigService.SaveConfigurationAsync(config);
await _adminConfigService.UpdateAsync(
c => { set(c); return null; }, "System (vulnerability sync)", "vuln-sync");
}
catch (Exception ex)
{
+4 -4
View File
@@ -16,6 +16,7 @@
"compression": "^1.8.2",
"express": "^5.2.1",
"fuse.js": "^7.5.0",
"jose": "^6.2.12",
"zod": "^4.6.5"
},
"bin": {
@@ -2596,10 +2597,9 @@
"license": "ISC"
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"dev": true,
"version": "6.2.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -27,6 +27,7 @@
"compression": "^1.8.2",
"express": "^5.2.1",
"fuse.js": "^7.5.0",
"jose": "^6.2.12",
"zod": "^4.6.5"
},
"devDependencies": {
@@ -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,305 @@
/**
* 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 { isSealedRefreshToken } = await import('../refresh-envelope.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);
expect(r.json.refresh_token).toBe('rt');
});
});
describe('sealed refresh tokens of a tenant client', () => {
/** A refresh token as a tenant client receives it from its code exchange. */
async function sealedToken(): Promise<string> {
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 });
vi.restoreAllMocks();
clearTenantClientCache();
expect(r.status).toBe(200);
return r.json.refresh_token as string;
}
const entraRefreshToken = (outbound: Outbound[]) =>
new URLSearchParams(outbound.find((o) => o.url.includes('/oauth2/v2.0/token'))!.body).get('refresh_token');
it('hands out the refresh token only sealed, never the Entra token', async () => {
const sealed = await sealedToken();
expect(sealed).not.toBe('rt');
expect(isSealedRefreshToken(sealed)).toBe(true);
});
it('redeems a sealed token without client_id at the registration tenant and seals the next one', async () => {
const sealed = await sealedToken();
const outbound = stubOutbound({ lookup: 'found' });
const r = await token({ grant_type: 'refresh_token', refresh_token: sealed });
expect(r.status).toBe(200);
const entra = outbound.find((o) => o.url.includes('/oauth2/v2.0/token'))!;
expect(new URL(entra.url).pathname).toBe(`/${TENANT}/oauth2/v2.0/token`);
expect(entraRefreshToken(outbound)).toBe('rt');
expect(isSealedRefreshToken(r.json.refresh_token as string)).toBe(true);
});
it('accepts the sealed token together with its own client_id', async () => {
const sealed = await sealedToken();
const outbound = stubOutbound({ lookup: 'found' });
const r = await token({ grant_type: 'refresh_token', refresh_token: sealed, client_id: CLIENT_ID });
expect(r.status).toBe(200);
expect(entraRefreshToken(outbound)).toBe('rt');
});
it('ends a sealed token once the registration is deleted, with or without client_id', async () => {
const sealed = await sealedToken();
for (const form of [
{ grant_type: 'refresh_token', refresh_token: sealed },
{ grant_type: 'refresh_token', refresh_token: sealed, client_id: CLIENT_ID },
]) {
const outbound = stubOutbound({ lookup: 'missing' });
const r = await token(form);
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);
vi.restoreAllMocks();
clearTenantClientCache();
}
});
it('refuses a sealed token presented by another client before any outbound call', async () => {
const sealed = await sealedToken();
for (const clientId of [`amc_${'f'.repeat(32)}`, 'some-dcr-client']) {
const outbound = stubOutbound({ lookup: 'found' });
const r = await token({ grant_type: 'refresh_token', refresh_token: sealed, client_id: clientId });
expect(r.status).toBe(400);
expect(r.json.error).toBe('invalid_grant');
expect(outbound).toHaveLength(0);
vi.restoreAllMocks();
}
});
it('refuses a tampered sealed token before any outbound call', async () => {
const parts = (await sealedToken()).split('.');
parts[3] = (parts[3][0] === 'A' ? 'B' : 'A') + parts[3].slice(1);
const outbound = stubOutbound({ lookup: 'found' });
const r = await token({ grant_type: 'refresh_token', refresh_token: parts.join('.') });
expect(r.status).toBe(400);
expect(r.json.error).toBe('invalid_grant');
expect(outbound).toHaveLength(0);
});
it('seals the next token when a tenant client still holds a raw one', async () => {
const outbound = stubOutbound({ lookup: 'found' });
const r = await token({ grant_type: 'refresh_token', refresh_token: 'rt', client_id: CLIENT_ID });
expect(r.status).toBe(200);
expect(entraRefreshToken(outbound)).toBe('rt');
expect(isSealedRefreshToken(r.json.refresh_token as string)).toBe(true);
});
});
@@ -0,0 +1,65 @@
/**
* Sealed refresh tokens (refresh-envelope.ts): a compact JWE only this server opens, recognised by its
* key id, carrying the Entra refresh token and the registration it was issued through.
*/
import { describe, it, expect } from 'vitest';
import crypto from 'node:crypto';
import { CompactEncrypt, decodeProtectedHeader } from 'jose';
import { isSealedRefreshToken, openRefreshToken, sealRefreshToken } from '../refresh-envelope.js';
const KEY = crypto.randomBytes(32);
const REG_ID = '0123456789abcdef0123456789abcdef';
const ENTRA_RT = '1.AXoAqGnBexample-entra-refresh-token_with.dots';
async function jwe(payload: unknown, kid: string, key: Uint8Array = KEY): Promise<string> {
return new CompactEncrypt(new TextEncoder().encode(JSON.stringify(payload)))
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', kid })
.encrypt(key);
}
describe('sealRefreshToken / openRefreshToken', () => {
it('round-trips the Entra token and the registration id', async () => {
const sealed = await sealRefreshToken(KEY, ENTRA_RT, REG_ID);
expect(await openRefreshToken(KEY, sealed)).toEqual({ refreshToken: ENTRA_RT, registrationId: REG_ID });
});
it('is a standard compact JWE (dir + A256GCM) that does not carry the token in the clear', async () => {
const sealed = await sealRefreshToken(KEY, ENTRA_RT, REG_ID);
expect(sealed.split('.')).toHaveLength(5);
expect(decodeProtectedHeader(sealed)).toEqual({ alg: 'dir', enc: 'A256GCM', kid: 'amc-rt-v1' });
expect(sealed).not.toContain(ENTRA_RT);
expect(Buffer.from(sealed.split('.')[3], 'base64url').toString('latin1')).not.toContain('AXoAqGnB');
});
it('opens nothing under another key', async () => {
const sealed = await sealRefreshToken(crypto.randomBytes(32), ENTRA_RT, REG_ID);
expect(await openRefreshToken(KEY, sealed)).toBeNull();
});
it('opens nothing that was tampered with', async () => {
const parts = (await sealRefreshToken(KEY, ENTRA_RT, REG_ID)).split('.');
parts[3] = (parts[3][0] === 'A' ? 'B' : 'A') + parts[3].slice(1);
expect(await openRefreshToken(KEY, parts.join('.'))).toBeNull();
});
it('opens nothing under a foreign key id or with an incomplete payload', async () => {
expect(await openRefreshToken(KEY, await jwe({ rt: ENTRA_RT, reg: REG_ID }, 'other'))).toBeNull();
expect(await openRefreshToken(KEY, await jwe({ rt: ENTRA_RT }, 'amc-rt-v1'))).toBeNull();
expect(await openRefreshToken(KEY, await jwe({ rt: ENTRA_RT, reg: 'not-a-registration' }, 'amc-rt-v1'))).toBeNull();
expect(await openRefreshToken(KEY, await jwe({ reg: REG_ID }, 'amc-rt-v1'))).toBeNull();
});
});
describe('isSealedRefreshToken', () => {
it('recognises only an envelope with our key id', async () => {
expect(isSealedRefreshToken(await sealRefreshToken(KEY, ENTRA_RT, REG_ID))).toBe(true);
expect(isSealedRefreshToken(await jwe({ rt: ENTRA_RT, reg: REG_ID }, 'other'))).toBe(false);
});
it('leaves Entra refresh tokens, JWTs and garbage on the unsealed path', () => {
expect(isSealedRefreshToken(ENTRA_RT)).toBe(false);
expect(isSealedRefreshToken('eyJhbGciOiJSUzI1NiJ9.eyJ0aWQiOiJ4In0.sig')).toBe(false);
expect(isSealedRefreshToken('a.b.c.d.e')).toBe(false);
expect(isSealedRefreshToken('')).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;
@@ -2980,6 +3029,11 @@ export interface OpsEventListResponse {
nextLink?: string;
}
/** Body of PATCH global/config: { "fields": { <fieldName>: <value>, ... } } with at least one field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take the field's own JSON type. */
export interface PatchAdminConfigurationRequest {
fields?: Record<string, unknown>;
}
/** Body of PATCH config/{tenantId}/fields: { "fields": { <fieldName>: <value>, ... }, "reason"?: "..." } with at least one field. Values take the field's own JSON type (TenantConfiguration). */
export interface PatchTenantConfigurationFieldsRequest {
fields?: Record<string, unknown>;
@@ -3321,7 +3375,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 +4225,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 +4748,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 +4780,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 +4958,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;
@@ -5240,7 +5296,7 @@ export interface UnmatchedSoftwareItem {
normalizedVersion?: string;
}
/** Response of PUT global/config: acknowledgement plus the stored admin configuration. */
/** Response of PATCH global/config: acknowledgement plus the stored admin configuration. */
export interface UpdateAdminConfigurationResponse {
success: boolean;
message: string;
@@ -5353,8 +5409,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",
+217 -30
View File
@@ -23,9 +23,12 @@
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';
import { isSealedRefreshToken, openRefreshToken, sealRefreshToken } from './refresh-envelope.js';
export { MAX_CLIENT_NAME_LENGTH, MAX_REDIRECT_URIS_PER_CLIENT, MAX_REDIRECT_URI_LENGTH };
@@ -216,6 +219,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',
@@ -266,6 +280,13 @@ const OAUTH_CLIENT_ID_KEY: Buffer = crypto
.update('autopilot-mcp/oauth-client-id/v1')
.digest();
// Key of the sealed refresh tokens handed to tenant-bound clients (refresh-envelope.ts): derived and
// domain-separated the same way, 32 bytes for A256GCM. Rotating the root secret ends every sealed token.
const OAUTH_REFRESH_ENVELOPE_KEY: Buffer = crypto
.createHmac('sha256', OAUTH_STATE_SIGNING_KEY)
.update('autopilot-mcp/oauth-refresh-envelope/v1')
.digest();
interface ClientIdPayload {
typ: 'client';
redirectUris: string[];
@@ -507,7 +528,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 +565,88 @@ 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. Such a client receives its refresh
// token only sealed with the registration id (refresh-envelope.ts), so the registration governs every
// refresh, whether or not the request names the client. 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 +687,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 +712,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 +751,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 +781,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 +833,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 +852,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 +919,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 +958,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 +974,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 +1048,43 @@ export function createOAuthRouter(): Router {
return;
}
// A tenant-bound client exchanges at its tenant's authority, and only while its registration exists.
// Its refresh tokens are sealed with the registration id: at refresh the registration comes from the
// envelope, so a copy redeemed without (or with another) client_id still meets the registration check.
// The first grant keys off client_id, which RFC 6749 §4.1.3 requires of a public client's code exchange.
let registrationId = tenantClientRegistrationId(params.client_id);
let refreshToken = params.refresh_token;
if (params.grant_type === 'refresh_token' && typeof refreshToken === 'string' && isSealedRefreshToken(refreshToken)) {
const opened = await openRefreshToken(OAUTH_REFRESH_ENVELOPE_KEY, refreshToken);
if (!opened) {
console.error('[oauth/token] sealed refresh token rejected: cannot be opened');
res.status(400).json({ error: 'invalid_grant', error_description: 'refresh token is invalid' });
return;
}
if (params.client_id && registrationId !== opened.registrationId) {
console.error(`[oauth/token] sealed refresh token of tenant client ${opened.registrationId} presented by another client_id`);
res.status(400).json({ error: 'invalid_grant', error_description: 'refresh token was issued to another client' });
return;
}
registrationId = opened.registrationId;
refreshToken = opened.refreshToken;
}
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();
@@ -923,13 +1094,13 @@ export function createOAuthRouter(): Router {
body.set('grant_type', params.grant_type);
if (params.code) body.set('code', params.code);
if (params.refresh_token) body.set('refresh_token', params.refresh_token);
if (refreshToken) body.set('refresh_token', refreshToken);
if (params.code_verifier) body.set('code_verifier', params.code_verifier);
// Always use server-defined SCOPES (see authorize endpoint comment)
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 +1124,22 @@ 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;
}
// Never hand a tenant-bound client the raw Entra refresh token. A raw token it still holds from
// before sealing existed is accepted above and comes back sealed, so clients migrate on refresh.
if (typeof data.refresh_token === 'string' && registrationId) {
data.refresh_token = await sealRefreshToken(OAUTH_REFRESH_ENVELOPE_KEY, data.refresh_token, registrationId);
}
}
res.status(tokenResponse.status).json(tokenResponse.status === 200 ? data : sanitizeTokenErrorBody(data));
} catch (err) {
// AbortSignal.timeout() rejects with a DOMException named 'TimeoutError'
@@ -0,0 +1,67 @@
/**
* Sealed refresh tokens for tenant-bound clients (portal registrations, D-280 / D-283).
*
* A self-hosted client keeps its users' refresh tokens in its own database. An Entra refresh token is
* bound to user and app but not to a tenant, and the proxy's public token endpoint adds the app's
* client secret for every caller — so a raw token copied out of that database could be redeemed
* without the client's `client_id`, past the registration check, and deleting the registration would
* stop the client but not the copy. The proxy therefore hands such a client its refresh token only
* sealed: a compact JWE (RFC 7516, `dir` + `A256GCM`) carrying the Entra token together with the
* registration id. Only this server can open it, and at refresh the registration comes from the
* envelope, never from a request parameter — a deleted registration ends every token issued through it.
*
* Encrypted, not merely signed: a signed envelope would expose the raw Entra token, which is
* redeemable on its own. Stateless like the signed client_id: the key is derived from
* OAuthStateSigningKey (oauth.ts), so any replica opens any envelope, and rotating that secret ends
* every sealed token (the users sign in again). Access and ID tokens are untouched Entra JWTs.
*/
import { CompactEncrypt, compactDecrypt, decodeProtectedHeader } from 'jose';
/** Key id in the protected header; a later key change adds a second id instead of breaking the first. */
const ENVELOPE_KID = 'amc-rt-v1';
const REGISTRATION_ID = /^[0-9a-f]{32}$/;
export interface OpenedRefreshToken {
/** The Entra refresh token. */
refreshToken: string;
/** The portal registration the token was issued through. */
registrationId: string;
}
/**
* True for a value this server sealed — a five-part compact JWE with our key id. Anything else
* (an Entra refresh token, garbage) is not an envelope and takes the unsealed path.
*/
export function isSealedRefreshToken(value: string): boolean {
if (value.split('.').length !== 5) return false;
try {
return decodeProtectedHeader(value).kid === ENVELOPE_KID;
} catch {
return false;
}
}
export async function sealRefreshToken(key: Uint8Array, refreshToken: string, registrationId: string): Promise<string> {
const payload = new TextEncoder().encode(JSON.stringify({ rt: refreshToken, reg: registrationId }));
return new CompactEncrypt(payload)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', kid: ENVELOPE_KID })
.encrypt(key);
}
/** The sealed token and its registration, or null for a tampered, foreign-key or malformed envelope. */
export async function openRefreshToken(key: Uint8Array, value: string): Promise<OpenedRefreshToken | null> {
try {
const { plaintext, protectedHeader } = await compactDecrypt(value, key, {
keyManagementAlgorithms: ['dir'],
contentEncryptionAlgorithms: ['A256GCM'],
});
if (protectedHeader.kid !== ENVELOPE_KID) return null;
const parsed = JSON.parse(new TextDecoder().decode(plaintext)) as { rt?: unknown; reg?: unknown };
if (typeof parsed.rt !== 'string' || !parsed.rt || typeof parsed.reg !== 'string' || !REGISTRATION_ID.test(parsed.reg)) {
return null;
}
return { refreshToken: parsed.rt, registrationId: parsed.reg };
} catch {
return null;
}
}
@@ -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,26 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// Outcome of <see cref="IConfigRepository.UpdateAdminConfigurationAsync"/>: either the mutation
/// rejected the change (<see cref="Error"/> set, nothing written) or it was accepted, in which case
/// <see cref="ChangedColumns"/> names the columns actually written (empty = no-op, no write).
/// </summary>
public sealed class AdminConfigurationUpdateResult
{
/// <summary>The mutation's rejection message; null when the change was accepted.</summary>
public string? Error { get; set; }
/// <summary>The stored configuration the mutation started from (fresh read).</summary>
public AdminConfiguration Before { get; set; } = default!;
/// <summary>The configuration after the mutation — what the row holds now when accepted.</summary>
public AdminConfiguration After { get; set; } = default!;
/// <summary>Columns written, server stamps excluded. Empty when the mutation changed nothing.</summary>
public IReadOnlyCollection<string> ChangedColumns { get; set; } = Array.Empty<string>();
}
}
@@ -66,7 +66,24 @@ namespace AutopilotMonitor.Shared.DataAccess
// --- Admin Configuration ---
Task<AdminConfiguration?> GetAdminConfigurationAsync();
Task<bool> SaveAdminConfigurationAsync(AdminConfiguration config);
/// <summary>
/// The one write path of the admin configuration row. It reads the row fresh, runs
/// <paramref name="mutate"/> on a copy and writes ONLY the columns whose value the mutation
/// changed, conditionally on the row's ETag (re-read and retry on a concurrent write).
/// Every other column keeps its stored value, including the ones only the agent release
/// pipeline writes (<c>LatestAgentV2*</c>) and columns this build does not know. A mutation
/// that changes nothing writes nothing. <paramref name="mutate"/> returns an error message
/// to reject the change (nothing is written) or null to accept it. Storage failures throw.
/// </summary>
Task<AdminConfigurationUpdateResult> UpdateAdminConfigurationAsync(
Func<AdminConfiguration, string?> mutate, string updatedBy, string? source = null);
/// <summary>
/// Creates the row from <paramref name="config"/> when none exists yet; never replaces an
/// existing row. True when this call created it.
/// </summary>
Task<bool> CreateAdminConfigurationIfMissingAsync(AdminConfiguration config);
// --- Preview Whitelist ---
Task<bool> IsInPreviewWhitelistAsync(string tenantId);
@@ -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; }
@@ -320,7 +320,7 @@ namespace AutopilotMonitor.Shared.Models
}
/// <summary>
/// Response of PUT global/config: acknowledgement plus the stored admin configuration.
/// Response of PATCH global/config: acknowledgement plus the stored admin configuration.
/// </summary>
// Declaration order == wire order.
public class UpdateAdminConfigurationResponse : IApiResponse
@@ -360,6 +360,16 @@ namespace AutopilotMonitor.Shared.Models
public string? Reason { get; set; }
}
/// <summary>
/// Body of PATCH global/config: <c>{ "fields": { &lt;fieldName&gt;: &lt;value&gt;, ... } }</c> with at least one
/// field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take
/// the field's own JSON type.
/// </summary>
public class PatchAdminConfigurationRequest : IApiRequest
{
public Dictionary<string, object>? Fields { get; set; }
}
/// <summary>Body of POST config/{tenantId}/revert.</summary>
public class RevertTenantConfigurationRequest : IApiRequest
{
@@ -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>
@@ -67,7 +67,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
{
question: "Which Autopilot scenarios are supported?",
answer:
"User-driven, pre-provisioned (white glove), and self-deploying or kiosk Autopilot flows, for Microsoft Entra joined and Hybrid joined devices alike. Autopilot Device Preparation is supported, including device association as a validation method, and Windows 365 Cloud PCs can be enabled per tenant.",
"User-driven, pre-provisioned (white glove), and self-deploying or kiosk Autopilot flows, for Microsoft Entra joined and Hybrid joined devices alike. Autopilot Device Preparation is supported, with device association or corporate identifiers as validation methods, or without any pre-registration by validating the device's Intune enrollment. Windows 365 Cloud PCs can be enabled per tenant.",
},
{
question: "Where is my enrollment data stored?",
@@ -5,14 +5,14 @@ import { useAuth } from "../../contexts/AuthContext";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
import { fromWireAdminConfiguration, toWireAdminConfiguration, type AdminConfiguration, type OpsAlertRule } from "@/types/adminConfig";
import { fromWireAdminConfiguration, type AdminConfiguration, type OpsAlertRule } from "@/types/adminConfig";
import { saveAdminConfigChanges } from "@/lib/adminConfigSave";
import type {
AdminConfiguration as WireAdminConfiguration,
GetAllPreviewNotificationEmailsResponse,
GetPreviewWhitelistResponse,
TestNotificationChannelRequest,
TestWebhookNotificationResponse,
UpdateAdminConfigurationResponse,
} from "@/utils/wire-types.generated";
import type { NotificationChannel } from "@/app/settings/types";
@@ -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,
@@ -366,14 +376,14 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
maxImeMsiDownloadSizeMB,
};
const result = await fetchJson<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
method: "PUT",
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
});
setAdminConfig(fromWireAdminConfiguration(result.config));
// selfServiceAppHomingEnabled gates every tenant's appHomingFunnelActive feature flag.
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
setSuccessMessage("Admin configuration saved successfully!");
// Only the fields this form changed go out; the backend writes nothing else (D-285).
const saved = await saveAdminConfigChanges(adminConfig, updatedConfig, getAccessToken);
if (saved) {
setAdminConfig(saved);
// selfServiceAppHomingEnabled gates every tenant's appHomingFunnelActive feature flag.
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
}
setSuccessMessage(saved ? "Admin configuration saved successfully!" : "No changes to save.");
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
console.error("Error saving admin configuration:", err);
@@ -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);
@@ -448,13 +460,10 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
diagnosticsGlobalLogPathsJson: JSON.stringify(paths),
};
const result = await fetchJson<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
method: "PUT",
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
});
setAdminConfig(fromWireAdminConfiguration(result.config));
const saved = await saveAdminConfigChanges(adminConfig, updatedConfig, getAccessToken);
if (saved) setAdminConfig(saved);
setGlobalDiagPaths(paths);
setSuccessMessage("Global diagnostics log paths saved successfully!");
setSuccessMessage(saved ? "Global diagnostics log paths saved successfully!" : "No changes to save.");
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) {
setError(apiErrorText(err, "Failed to save diagnostics paths"));
@@ -492,18 +501,15 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
excessiveEventAutoActionDurationHours: newAutoActionDurationHours,
};
const result = await fetchJson<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
method: "PUT",
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
});
setAdminConfig(fromWireAdminConfiguration(result.config));
const saved = await saveAdminConfigChanges(adminConfig, updatedConfig, getAccessToken);
if (saved) setAdminConfig(saved);
setOpsAlertRules(rules);
setOpsNotificationChannels(channels);
setExcessiveEventCountThreshold(newExcessiveThreshold);
setExcessiveEventAutoActionMode(newAutoActionMode);
setExcessiveEventAutoActionThreshold(newAutoActionThreshold);
setExcessiveEventAutoActionDurationHours(newAutoActionDurationHours);
setSuccessMessage("Alert configuration saved successfully!");
setSuccessMessage(saved ? "Alert configuration saved successfully!" : "No changes to save.");
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) {
setError(apiErrorText(err, "Failed to save alert configuration"));
@@ -558,6 +564,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>
@@ -261,7 +261,7 @@ export function TenantAdminSection({
<div className="flex flex-wrap items-center gap-1.5">
<div className="font-medium text-gray-900 truncate">{principalLabel(admin.upn)}</div>
{isApplication && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700" title="Service principal — calls the API with an app-only token; read-only">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700" title="Service principal — automation with its own token, or an app whose users connect through it; read-only">
App
</span>
)}
@@ -384,10 +384,12 @@ export function TenantAdminSection({
</div>
{addingApplication && (
<p className="text-xs text-purple-700 mb-2">
An application in this tenant that calls the API with an app-only token (for example an automation
using a federated credential). It is always read-only (Viewer) and must hold the{" "}
<span className="font-mono">access_as_application</span> permission for Autopilot Monitor, granted
by admin consent in your Entra tenant.
An application in this tenant, always read-only (Viewer). Automation calling with an app-only token
(for example through a federated credential) needs the{" "}
<span className="font-mono">access_as_application</span> permission. An app that signs users in and
sends their tokens needs the delegated{" "}
<span className="font-mono">access_as_user</span> permission, and its users can only read. Either is
granted by admin consent in the tenant.
</p>
)}
<div className="flex space-x-2">
@@ -23,6 +23,7 @@ import { api } from "@/lib/api";
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
import { classifyClientId, legacyConfigured } from "@/lib/authApp";
import { hasAnyDeviceValidation } from "@/lib/deviceValidation";
import { appHomingErrorMessage } from "@/lib/appHoming";
import { trackEvent } from "@/lib/appInsights";
import { TenantAdminSection } from "./TenantAdminSection";
@@ -265,7 +266,7 @@ function TenantManagementSectionInner({
const clearFilters = () => setFilters(EMPTY_FILTERS);
// Statistics (always over all tenants, not filtered)
const readyCount = tenants.filter(t => t.validateAutopilotDevice).length;
const readyCount = tenants.filter(t => hasAnyDeviceValidation(t)).length;
const waitlistCount = tenants.filter(t => onWaitlist(t, filterCtx)).length;
const totalCount = tenants.length;
const plans = planOverview(tenants, nowMs);
@@ -706,7 +707,7 @@ function TenantManagementSectionInner({
Paying
</span>
)}
{tenant.validateAutopilotDevice && (
{hasAnyDeviceValidation(tenant) && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Ready
</span>
@@ -1185,6 +1186,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"
@@ -2,10 +2,11 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import SaveResetBar from "../../settings/components/SaveResetBar";
import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel";
import { fromWireAdminConfiguration, toWireAdminConfiguration, type AdminConfiguration } from "@/types/adminConfig";
import { fromWireAdminConfiguration, type AdminConfiguration } from "@/types/adminConfig";
import { saveAdminConfigChanges } from "@/lib/adminConfigSave";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type {
@@ -16,7 +17,6 @@ import type {
TriggerMsrcSyncResponse,
TriggerNvdCacheRefreshResponse,
TriggerVulnerabilityDataSyncResponse,
UpdateAdminConfigurationResponse,
} from "@/utils/wire-types.generated";
interface VulnerabilityDataSectionProps {
@@ -168,12 +168,10 @@ export function VulnerabilityDataSection({
vulnerabilityCorrelationEnabled,
};
const result = await fetchJson<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
method: "PUT",
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
});
onAdminConfigUpdated(fromWireAdminConfiguration(result.config));
setSuccessMessage("Vulnerability configuration saved successfully!");
// Only the fields this section changed go out; the backend writes nothing else (D-285).
const saved = await saveAdminConfigChanges(adminConfig, updatedConfig, getAccessToken);
if (saved) onAdminConfigUpdated(saved);
setSuccessMessage(saved ? "Vulnerability configuration saved successfully!" : "No changes to save.");
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) {
const message = apiErrorText(err, "Failed to save vulnerability configuration");
@@ -111,6 +111,12 @@ describe("matchesTenantFilters", () => {
expect(matchesTenantFilters(msp, payingPro, ctx())).toBe(false);
});
it("counts a tenant as ready with any device validation, not just Autopilot", () => {
expect(tenantFacetValues(tenant({ validateIntuneDeviceBinding: true }), ctx()).status).toContain("ready");
expect(tenantFacetValues(tenant({ validateDeviceAssociation: true }), ctx()).status).toContain("ready");
expect(tenantFacetValues(tenant(), ctx()).status).not.toContain("ready");
});
it("treats Status as multi-valued: a ready waitlisted tenant matches either value", () => {
const t = tenant({ tenantId: "w", validateAutopilotDevice: true });
const waitlisted = ctx({ isWaitlisted: (id) => id === "w" });

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