Compare commits

...

14 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
63 changed files with 1629 additions and 429 deletions
@@ -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);
@@ -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");
}
}
@@ -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");
@@ -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.
}
}
@@ -931,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 },
@@ -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");
}
}
}
}
@@ -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)));
}
}
@@ -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)
@@ -479,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).
@@ -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>
@@ -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)
@@ -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": {
@@ -14,6 +14,7 @@ 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}`;
@@ -209,5 +210,96 @@ describe('/oauth/token with a tenant client', () => {
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);
});
});
@@ -3029,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>;
@@ -5291,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;
@@ -28,6 +28,7 @@ import { API_BASE_URL, ENTRA_LOGIN_BASE_URL, getPublicBaseUrl } from './config.j
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 };
@@ -279,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[];
@@ -563,7 +571,9 @@ async function resolveRegisteredClient(
// portal and receives client_id `amc_<registrationId>`. The registration is that client's allowlist
// entry — the global vendor allowlist keeps governing dynamic registration — and it binds the flow to
// the registering tenant: authorize and token go to that tenant's Entra authority, and the token
// endpoint discards a token whose tid is not the registration's. Registrations live in the backend;
// 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.
@@ -1039,7 +1049,26 @@ export function createOAuthRouter(): Router {
}
// A tenant-bound client exchanges at its tenant's authority, and only while its registration exists.
const registrationId = tenantClientRegistrationId(params.client_id);
// 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);
@@ -1065,7 +1094,7 @@ 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);
@@ -1105,6 +1134,11 @@ export function createOAuthRouter(): Router {
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) {
@@ -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;
}
}
@@ -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);
@@ -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
{
@@ -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";
@@ -376,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);
@@ -460,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"));
@@ -504,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"));
@@ -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>
@@ -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" });
@@ -12,6 +12,8 @@
* app-registration classifier (reads env, `null` outside the dual-app parallel window).
*/
import { hasAnyDeviceValidation } from "@/lib/deviceValidation";
export type PlanEdition = "community" | "pro" | "pro-trial" | "pro-msp";
export type TrialState = "active" | "expired" | "never";
export type AppValue = "primary" | "legacy" | "unknown";
@@ -30,7 +32,12 @@ export interface TenantFilterFields {
disabled: boolean;
disabledReason?: string | null;
mcpDisabled: boolean;
/** Device-validation flags; "ready" = any of them on (see hasAnyDeviceValidation). */
validateAutopilotDevice: boolean;
validateCorporateIdentifier?: boolean;
validateDeviceAssociation?: boolean;
validateCloudPcDevice?: boolean;
validateIntuneDeviceBinding?: boolean;
}
export interface TenantFilterContext {
@@ -178,7 +185,7 @@ export function onWaitlist(t: TenantFilterFields, ctx: TenantFilterContext): boo
export function tenantFacetValues(t: TenantFilterFields, ctx: TenantFilterContext): Record<FacetKey, readonly string[]> {
const status: string[] = [];
const offboarding = isOffboardingTombstone(t);
if (t.validateAutopilotDevice) status.push("ready");
if (hasAnyDeviceValidation(t)) status.push("ready");
if (onWaitlist(t, ctx)) status.push("waitlist");
if (offboarding) status.push("offboarding");
if (t.disabled && !offboarding) status.push("suspended");
@@ -129,7 +129,7 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
validateCorporateIdentifier: { section: "Security & Validation", label: "Validate Corporate Identifier" },
validateDeviceAssociation: { section: "Security & Validation", label: "Validate Device Association" },
validateCloudPcDevice: { section: "Security & Validation", label: "Validate Cloud PC Device" },
validateIntuneDeviceBinding: { section: "Security & Validation", label: "Validate Intune Device Binding" },
validateIntuneDeviceBinding: { section: "Security & Validation", label: "Validate Intune Enrollment" },
allowInsecureAgentRequests: { section: "Security & Validation", label: "Allow Insecure Agent Requests" },
customRateLimitRequestsPerMinute: { section: "Security & Validation", label: "Device API Rate Limit Override" },
customUserRateLimitRequestsPerMinute: { section: "Security & Validation", label: "MCP & Integrations API Rate Limit Override" },
@@ -3,19 +3,19 @@ import { summarizeTenantSecurityConfig } from "../useTenantSecurityConfig";
describe("summarizeTenantSecurityConfig", () => {
it("shows the app-homing banner only on an explicit true", () => {
expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true, appHomingFunnelActive: true }).appHomingFunnelActive).toBe(true);
expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true, appHomingFunnelActive: false }).appHomingFunnelActive).toBe(false);
expect(summarizeTenantSecurityConfig({ deviceValidationEnabled: true, appHomingFunnelActive: true }).appHomingFunnelActive).toBe(true);
expect(summarizeTenantSecurityConfig({ deviceValidationEnabled: true, appHomingFunnelActive: false }).appHomingFunnelActive).toBe(false);
// Older backend without the field: never nag.
expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true }).appHomingFunnelActive).toBe(false);
expect(summarizeTenantSecurityConfig({ deviceValidationEnabled: true }).appHomingFunnelActive).toBe(false);
});
it("keys the validation banner on the flag and the contact nag on Pro editions only", () => {
const community = summarizeTenantSecurityConfig({ validateAutopilotDevice: false, edition: "community", contactEmailSet: false });
expect(community.serialValidationEnabled).toBe(false);
const community = summarizeTenantSecurityConfig({ deviceValidationEnabled: false, edition: "community", contactEmailSet: false });
expect(community.deviceValidationEnabled).toBe(false);
expect(community.proContactMissing).toBe(false);
const pro = summarizeTenantSecurityConfig({ validateAutopilotDevice: true, edition: "pro", contactEmailSet: false, companyNameSet: true });
expect(pro.serialValidationEnabled).toBe(true);
const pro = summarizeTenantSecurityConfig({ deviceValidationEnabled: true, edition: "pro", contactEmailSet: false, companyNameSet: true });
expect(pro.deviceValidationEnabled).toBe(true);
expect(pro.proContactMissing).toBe(true);
expect(pro.proContactMissingParts).toEqual(["contact address"]);
});
@@ -11,7 +11,8 @@ type NotifyError = (title: string, err: unknown, key?: string, fallback?: string
/** The subset of the feature-flags response the dashboard banners read. */
export interface TenantConfigurationSummary {
validateAutopilotDevice: boolean;
/** At least one device-validation method is enabled (backend HasAnyDeviceValidation). */
deviceValidationEnabled: boolean;
edition?: string;
contactEmailSet?: boolean;
companyNameSet?: boolean;
@@ -26,7 +27,7 @@ interface User {
export interface TenantSecuritySummary {
/** null while loading or on error; the red banner keys on `=== false`. */
serialValidationEnabled: boolean | null;
deviceValidationEnabled: boolean | null;
/**
* Pro tenant (incl. trial) with an incomplete contact profile (address and/or company
* name) — drives the amber "complete your contact details" banner. Requires an EXPLICIT
@@ -44,7 +45,7 @@ export interface TenantSecuritySummary {
}
const EMPTY_SUMMARY: TenantSecuritySummary = {
serialValidationEnabled: null,
deviceValidationEnabled: null,
proContactMissing: false,
proContactMissingParts: [],
appHomingFunnelActive: false,
@@ -55,7 +56,7 @@ export function summarizeTenantSecurityConfig(data: TenantConfigurationSummary):
const isPro = data.edition === "pro" || data.edition === "enterprise";
const missingParts = isPro ? missingContactProfileParts(data) : [];
return {
serialValidationEnabled: !!data.validateAutopilotDevice,
deviceValidationEnabled: !!data.deviceValidationEnabled,
proContactMissing: missingParts.length > 0,
proContactMissingParts: missingParts,
appHomingFunnelActive: data.appHomingFunnelActive === true,
@@ -64,7 +65,7 @@ export function summarizeTenantSecurityConfig(data: TenantConfigurationSummary):
/**
* Fetches the tenant's feature-flags summary to drive the dashboard banners:
* the red "Autopilot Device Validation is disabled" banner, the amber
* the red "no device validation enabled" banner, the amber
* Pro-without-contact-address banner, and the blue app-registration migration banner.
*
* Skips the fetch for users without a tenant role (they never see the dashboard).
@@ -257,7 +257,7 @@ function HomeContent() {
}
}, [user, router]);
const { serialValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, notifyError);
const { deviceValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, notifyError);
// Disable global-scope mode for users without platform scope. A read-only Global Reader keeps it
// (their cross-tenant view is read-only-safe; writes are gated separately + backend-enforced).
@@ -367,7 +367,7 @@ function HomeContent() {
<AppHomingBanner />
)}
{serialValidationEnabled === false && (
{deviceValidationEnabled === false && (
<div className="mb-6 bg-red-600 border-2 border-red-700 rounded-xl p-5 shadow-lg">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-start gap-3">
@@ -375,9 +375,9 @@ function HomeContent() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div>
<p className="text-base font-bold text-white">Action required: Autopilot Device Validation is disabled</p>
<p className="text-base font-bold text-white">Action required: No device validation is enabled</p>
<p className="text-sm text-red-100 mt-0.5">
Agent ingestion is blocked. Enable Autopilot Device Validation in Settings to start monitoring devices.
Agent ingestion is blocked. Enable a device validation method in Settings to start monitoring devices.
</p>
</div>
</div>
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
const LAST_UPDATED = "21 September 2026";
const LAST_UPDATED = "25 September 2026";
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`;
@@ -208,7 +208,7 @@ export default function PrivacyPage() {
<h2 className="text-lg font-semibold text-gray-900 mt-6">How Your Data Is Protected</h2>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li><strong>Device authentication by mutual TLS</strong> using the Intune MDM client certificate, validated against pinned Intune root CAs rather than the operating system trust store — and rejected if no trust anchor loads</li>
<li><strong>Device registration validation via Microsoft Graph</strong> — only devices your tenant actually knows are accepted (Windows Autopilot registration, corporate device identifiers, or the Windows 365 Cloud PC inventory), with an optional hardware allow-list on top</li>
<li><strong>Device validation via Microsoft Graph</strong> — only devices your tenant actually knows are accepted (Windows Autopilot registration, corporate device identifiers, device association, the Windows 365 Cloud PC inventory, or the Intune enrollment of the certificate&apos;s device, as enabled per tenant), with an optional hardware allow-list on top</li>
<li><strong>Entra ID authentication</strong> for portal users with a restricted signing-algorithm allow-list and identity PII logging disabled</li>
<li><strong>Fail-closed authorization</strong> — every API route must be registered with an access policy; an unregistered route is unreachable</li>
<li><strong>Structural tenant isolation</strong> — storage partitioning by tenant, with the tenant identity taken from the validated token and never from a client-supplied header</li>
@@ -175,8 +175,8 @@ function joinTypeLabel(session: Session): string {
/**
* Which backend device-validation path admitted the device at session registration
* (SecurityValidator: Autopilot S/N lookup → Corporate Identifier fallback; Bootstrap
* token for pre-MDM sessions; Device Association once DevPrep becomes a hard gate).
* (SecurityValidator chain: Autopilot → Corporate Identifier → Device Association → Cloud PC →
* Intune Enrollment; Bootstrap token for pre-MDM sessions).
* Null (item hidden) for legacy sessions or tenants with device validation disabled.
*/
function validatedByLabel(session: Session): string | null {
@@ -186,6 +186,7 @@ function validatedByLabel(session: Session): string | null {
case "DeviceAssociation": return "Device Association";
case "Bootstrap": return "Bootstrap Token (pre-MDM)";
case "CloudPc": return "Windows 365 Cloud PC";
case "IntuneEnrollment": return "Intune Enrollment";
default: return null;
}
}
@@ -18,15 +18,11 @@ type ValidationTrigger = "autopilot" | "corporate" | "device-preparation";
// Human label for a validation trigger.
const validationLabel = (t: ValidationTrigger) =>
t === "corporate" ? "Corporate Identifier Validation"
: t === "device-preparation" ? "DevPrep Device Association Validation"
: t === "device-preparation" ? "Device Association Validation"
: "Autopilot Device Validation";
// Enable-confirmation suffix. DevPrep is shadow-mode (no hard gate), so its wording differs
// from the agent-gating validations.
const validationEnabledSuffix = (t: ValidationTrigger) =>
t === "device-preparation"
? " enabled (shadow mode — does not block enrollment)."
: " enabled. Backend agent endpoints are now unlocked for this tenant.";
// Enable-confirmation suffix: every consent-driven validation is an accepting agent gate.
const VALIDATION_ENABLED_SUFFIX = " enabled. Backend agent endpoints are now unlocked for this tenant.";
import { parseSasExpiry } from "./components/DiagnosticsSection";
import { COMMUNITY_DEFAULT, parseEditionInfo, type EditionInfo } from "@/lib/edition";
import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath, NotificationChannel, LEGACY_CHANNEL_ID } from "./types";
@@ -952,7 +948,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const saved = await persistValidation(trigger);
if (!saved) return "failed";
setSuccessMessage(`Access is already approved by your organization — ${validationLabel(trigger)}${validationEnabledSuffix(trigger)}`);
setSuccessMessage(`Access is already approved by your organization — ${validationLabel(trigger)}${VALIDATION_ENABLED_SUFFIX}`);
return "reconciled";
},
[probeAccessCheck, persistValidation],
@@ -1122,7 +1118,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
if (attempts > 1) trackEvent("consent_verify_propagated", { trigger, attempts: String(attempts) });
const saved = await persistValidation(reconcileTrigger);
if (saved) {
setSuccessMessage(`${validationLabel(reconcileTrigger)}${validationEnabledSuffix(reconcileTrigger)}`);
setSuccessMessage(`${validationLabel(reconcileTrigger)}${VALIDATION_ENABLED_SUFFIX}`);
}
} else if (probe === "transient") {
trackEvent("consent_verify_failed", { trigger, stage: "role-propagating", attempts: String(attempts - 1) });
@@ -1335,10 +1331,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
}, [saveConfiguration]);
/**
* Toggle the cert-to-device binding check (Global-Admin-only preview). No consent flow -
* DeviceManagementManagedDevices.Read.All is an Optional Graph capabilities add-on granted
* with the script; without it the backend records "PermissionMissing" and nothing else, and
* because the check runs in shadow mode it never affects an enrollment either way.
* Toggle Intune Enrollment Validation (field name validateIntuneDeviceBinding). No consent
* flow — DeviceManagementManagedDevices.Read.All is an Optional Graph capabilities add-on
* granted with the script; until it is granted no device is admitted through this option
* (the backend answers 503 Retry-After, like a missing core consent).
*/
const handleToggleIntuneDeviceBinding = useCallback(async (newValue: boolean) => {
setValidateIntuneDeviceBinding(newValue);
@@ -207,7 +207,7 @@ export default function AdminManagementSection({
<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 — an application calling 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>
)}
@@ -322,7 +322,7 @@ export default function AdminManagementSection({
<div className="flex flex-wrap items-center gap-2 mb-2">
<p className="text-sm text-gray-500">
{addingApplication
? "Enter the application (client) ID of a service principal in your tenant. It is always read-only (Viewer) and must hold the access_as_application permission for Autopilot Monitor, granted by admin consent in your Entra tenant."
? "Enter the application (client) ID of an app in your Entra tenant; it is always read-only (Viewer). Automation calling with its own token needs the access_as_application permission. An app that signs users in and sends their tokens needs the delegated access_as_user permission, and its users can only read. Grant either by admin consent in your Entra tenant."
: "Enter the user email (UPN) and select a role to grant access."}
</p>
</div>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { ModalPortal } from "@/components/ModalPortal";
@@ -13,10 +14,12 @@ interface AutopilotValidationSectionProps {
/** Autopilot device preparation "Device association" — same Graph permission as the two above. */
validateDeviceAssociation: boolean;
setValidateDeviceAssociation: (value: boolean) => void;
/** Cert-to-device binding - Global-Admin-only preview. Provided when `showIntuneDeviceBindingToggle=true`. */
/** Intune Enrollment Validation — admits enrolled Intune devices without pre-registration. */
validateIntuneDeviceBinding?: boolean;
/** Toggle + persist Intune Enrollment Validation (permission via the Optional Graph capabilities add-on, no consent dialog). */
onToggleIntuneDeviceBinding?: (v: boolean) => void | Promise<void>;
showIntuneDeviceBindingToggle?: boolean;
/** Whether the IntuneDeviceBinding add-on is granted; null while unknown (loading, transient). */
intuneEnrollmentPermission?: boolean | null;
/** Windows 365 Cloud PC validation — fallback gate for Cloud PCs (never Autopilot-registered). */
validateCloudPcDevice?: boolean;
/** Toggle + persist Cloud PC validation in one shot (permission comes via the Optional Graph capabilities add-on, no consent dialog). */
@@ -40,7 +43,7 @@ export default function AutopilotValidationSection({
setValidateDeviceAssociation,
validateIntuneDeviceBinding = false,
onToggleIntuneDeviceBinding,
showIntuneDeviceBindingToggle = false,
intuneEnrollmentPermission = null,
validateCloudPcDevice = false,
onToggleCloudPc,
autopilotConsentInProgress,
@@ -48,8 +51,9 @@ export default function AutopilotValidationSection({
onBeginConsent,
onDetectExistingAccess,
}: AutopilotValidationSectionProps) {
const anyValidationEnabled = validateAutopilotDevice || validateCorporateIdentifier || validateDeviceAssociation || validateCloudPcDevice;
const [disableConfirm, setDisableConfirm] = useState<'autopilot' | 'corporate' | 'device-association' | 'cloudpc' | null>(null);
const enabledValidations = [validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice, validateIntuneDeviceBinding];
const anyValidationEnabled = enabledValidations.some(Boolean);
const [disableConfirm, setDisableConfirm] = useState<'autopilot' | 'corporate' | 'device-association' | 'cloudpc' | 'intune-enrollment' | null>(null);
// The three serial-based validations share one Graph permission: the first one enabled
// carries the admin consent, every further one is a plain persisted toggle.
const consentAlreadyCarried = validateAutopilotDevice || validateCorporateIdentifier || validateDeviceAssociation;
@@ -94,6 +98,16 @@ export default function AutopilotValidationSection({
}
};
// Intune Enrollment Validation: add-on permission (DeviceManagementManagedDevices.Read.All via
// the grant script) like Cloud PC — direct toggle+persist, no consent dialog.
const handleToggleIntuneEnrollment = () => {
if (validateIntuneDeviceBinding) {
setDisableConfirm('intune-enrollment');
} else if (onToggleIntuneDeviceBinding) {
void onToggleIntuneDeviceBinding(true);
}
};
const confirmDisable = () => {
if (disableConfirm === 'autopilot') {
setValidateAutopilotDevice(false);
@@ -103,6 +117,8 @@ export default function AutopilotValidationSection({
setValidateDeviceAssociation(false);
} else if (disableConfirm === 'cloudpc' && onToggleCloudPc) {
void onToggleCloudPc(false);
} else if (disableConfirm === 'intune-enrollment' && onToggleIntuneDeviceBinding) {
void onToggleIntuneDeviceBinding(false);
}
setDisableConfirm(null);
};
@@ -245,27 +261,41 @@ export default function AutopilotValidationSection({
</div>
)}
{/* Cert-to-device binding - GA-gated preview, shadow-mode (no enrollment block) */}
{showIntuneDeviceBindingToggle && onToggleIntuneDeviceBinding && (
<div className="border-t border-gray-100 pt-5 space-y-3" data-testid="intune-device-binding-toggle">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-700 tracking-wide">Certificate Device Binding</p>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider bg-purple-100 text-purple-800">
Preview · GA only
</span>
</div>
{/* Intune Enrollment — no pre-registration; permission via the Optional Graph capabilities add-on */}
{onToggleIntuneDeviceBinding && (
<div className="border-t border-gray-100 pt-5 space-y-3" data-testid="intune-enrollment-validation-toggle">
<p className="text-sm font-semibold text-gray-700 tracking-wide">Without pre-registration</p>
<label className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900">Enable Intune Device Binding Validation</p>
<p className="text-sm font-medium text-gray-900">Enable Intune Enrollment Validation</p>
<p className="text-sm text-gray-500">
Checks that the Intune device id in the agent{"'"}s client certificate belongs to a device this tenant actually enrolled. Currently runs in <strong>shadow mode</strong> - the result is recorded as telemetry only and does NOT block enrollment. Requires the optional <strong>DeviceManagementManagedDevices.Read.All</strong> permission - grant the{" "}
<strong>IntuneDeviceBinding</strong> add-on under <em>Optional Graph capabilities</em>.
Accepts every device enrolled in this tenant&apos;s Intune, matched by the Intune device id in the
agent&apos;s MDM certificate. No Autopilot registration, corporate identifier or device association is
needed, which suits Autopilot device preparation without pre-registration. Checked last, only when no
option above matched.
</p>
<p className="text-sm text-gray-500 mt-1">
This reaches exactly as far as your Intune enrollment restrictions: if personal devices may enroll,
they are accepted too. For corporate devices only, block personal enrollment or prefer the options above.
Requires the optional <strong>DeviceManagementManagedDevices.Read.All</strong>{" "}permission — grant the{" "}
<strong>IntuneDeviceBinding</strong>{" "}add-on under{" "}
<Link href="/settings/tenant/graph-permissions" className="underline underline-offset-2 hover:text-gray-700">
Optional Graph capabilities
</Link>.
</p>
{intuneEnrollmentPermission === true && (
<p className="text-xs font-medium text-emerald-700 mt-1">Permission granted.</p>
)}
{intuneEnrollmentPermission === false && (
<p className="text-xs font-medium text-amber-700 mt-1">
Permission not granted yet. No device is accepted this way until the add-on is granted.
</p>
)}
</div>
<button
onClick={() => { void onToggleIntuneDeviceBinding(!validateIntuneDeviceBinding); }}
onClick={handleToggleIntuneEnrollment}
disabled={saving}
aria-label="Toggle Intune device binding validation"
aria-label="Toggle Intune enrollment validation"
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${validateIntuneDeviceBinding ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${validateIntuneDeviceBinding ? 'translate-x-6' : 'translate-x-1'}`} />
@@ -305,7 +335,8 @@ export default function AutopilotValidationSection({
{disableConfirm === 'autopilot' ? 'Autopilot Device Validation'
: disableConfirm === 'corporate' ? 'Corporate Identifier Validation'
: disableConfirm === 'device-association' ? 'Device Association Validation'
: 'Windows 365 Cloud PC Validation'}
: disableConfirm === 'intune-enrollment' ? 'Intune Enrollment Validation'
: 'Windows 365 Cloud PC Validation'}
</p>
</div>
</div>
@@ -313,7 +344,7 @@ export default function AutopilotValidationSection({
<p className="text-sm text-gray-700 mb-2">
Are you sure you want to disable this validation?
</p>
{[validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice].filter(Boolean).length === 1 && (
{enabledValidations.filter(Boolean).length === 1 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-800">
This is the last active validation. Disabling it will cause the backend to <strong>reject all agent requests</strong> for this tenant.
</div>
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../../../contexts/AuthContext";
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { api } from "@/lib/api";
import { patchAdminConfigFields } from "@/lib/adminConfigSave";
import { useTenantList } from "@/hooks/useTenantList";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
import { isApplicationKey, looksLikeGuid, principalLabel } from "@/utils/principalKeys";
@@ -11,7 +12,6 @@ import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import type {
AddMcpUserRequest,
AdminConfiguration,
GetMcpUsersResponse,
McpUserEntry,
PlanTierDefinition as PlanTierDefinitionWire,
@@ -111,13 +111,8 @@ export default function McpUsersSection() {
setError(null);
setSuccessMessage(null);
// Read current global config, update McpAccessPolicy, save back
const config = await fetchJson<AdminConfiguration>(api.globalConfig.get(), getAccessToken);
await fetchOk(api.globalConfig.get(), getAccessToken, {
method: "PUT",
body: jsonBody<AdminConfiguration>({ ...config, mcpAccessPolicy: newPolicy }),
});
// Only this one field is sent; the backend writes nothing else (D-285).
await patchAdminConfigFields({ mcpAccessPolicy: newPolicy }, getAccessToken);
setPolicy(newPolicy);
setSuccessMessage(`MCP access policy changed to "${POLICY_LABELS[newPolicy]}".`);
@@ -107,7 +107,7 @@ export default function NotRegisteredDevicesInsights({
</h3>
{hasDevices && (
<p className="text-sm text-gray-500 mt-0.5">
Devices rejected with HTTP 403 because they were not found in this tenant&apos;s Autopilot or Corporate Identifier registry
Devices rejected with HTTP 403 because no enabled device validation method matched them
</p>
)}
</div>
@@ -124,8 +124,8 @@ export default function NotRegisteredDevicesInsights({
{!hasDevices && (
<div className="px-6 py-4">
<p className="text-sm text-gray-500">
When a device attempts to enroll but is not part of your Autopilot or Corporate Identifier
scope, it appears here. An empty list means every device that reached the backend was in scope.
When a device attempts to enroll but none of your enabled device validation methods matches it,
it appears here. An empty list means every device that reached the backend was in scope.
</p>
</div>
)}
@@ -1,8 +1,10 @@
"use client";
import { useEffect } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useTenant } from "@/contexts/TenantContext";
import { useGlobalAdminUi } from "@/hooks/useGlobalAdminUi";
import { useAuthenticatedFetch } from "@/hooks/useAuthenticatedFetch";
import { api } from "@/lib/api";
import { buildAddOnGrantCommand } from "@/lib/appHoming";
import { legacyConfigured, primaryClientId, switchAuthApp } from "@/lib/authApp";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -12,6 +14,8 @@ import { TenantNotifications } from "../../TenantNotifications";
import { AppHomingAddOnStep } from "../../components/AppHomingAddOnStep";
import AutopilotValidationSection from "../../components/AutopilotValidationSection";
import NotRegisteredDevicesInsights from "../../components/NotRegisteredDevicesInsights";
import type { GetGraphPermissionsStatusResponse } from "@/utils/wire-types.generated";
import { addOnGranted, INTUNE_ENROLLMENT_FEATURE } from "@/lib/deviceValidation";
export function SectionAutopilotValidation() {
const {
@@ -32,11 +36,15 @@ export function SectionAutopilotValidation() {
const { getAccessToken } = useAuth();
const { tenantId } = useTenant();
// Cert-device binding stays Global-Admin-only: only the operator can turn the binding check
// on while its enrollment-race behaviour is still being measured. Follows the Global-Admin
// VIEW, so switching it off (or presenting in demo mode) yields the real tenant-admin section.
// Read before the early return below — hooks may not sit behind a conditional.
const showIntuneDeviceBindingToggle = useGlobalAdminUi();
// Add-on permission state for the Intune Enrollment option — the same status endpoint the
// Optional Graph capabilities page reads. Best effort: a failure leaves the hint hidden.
// Hooks sit before the early return below — they may not be conditional.
const permissionStatus = useAuthenticatedFetch<GetGraphPermissionsStatusResponse>({ onError: () => {} });
const fetchPermissionStatus = permissionStatus.execute;
useEffect(() => {
if (!tenantId || !canEditConfig) return;
void fetchPermissionStatus(api.graphPermissions.status(tenantId), undefined, { silent: true });
}, [tenantId, canEditConfig, fetchPermissionStatus]);
// Validation gates + the Entra admin-consent flow are tenant-admin territory —
// Operators do not see this section at all.
@@ -178,7 +186,7 @@ export function SectionAutopilotValidation() {
validateCloudPcDevice={validateCloudPcDevice}
validateIntuneDeviceBinding={validateIntuneDeviceBinding}
onToggleIntuneDeviceBinding={handleToggleIntuneDeviceBinding}
showIntuneDeviceBindingToggle={showIntuneDeviceBindingToggle}
intuneEnrollmentPermission={addOnGranted(permissionStatus.data, INTUNE_ENROLLMENT_FEATURE)}
onToggleCloudPc={handleToggleCloudPcValidation}
autopilotConsentInProgress={autopilotConsentInProgress}
saving={savingSection === "autopilotValidation"}
@@ -42,9 +42,8 @@ export interface TenantConfiguration {
validateAutopilotDevice: boolean;
validateCorporateIdentifier?: boolean;
/**
* DevPrep Device Association validation (shadow mode during Private Preview).
* Looks devices up via Graph `tenantAssociatedDevices` but does NOT block enrollment.
* UI surface is gated to Global Admins until DevPrep ships GA.
* Device preparation "Device association" validation: matches the serial against Graph
* `tenantAssociatedDevices`. An accepting agent gate like the other validations.
*/
validateDeviceAssociation?: boolean;
/**
@@ -55,6 +54,12 @@ export interface TenantConfiguration {
* permission ("W365CloudPcValidation" in Optional Graph capabilities).
*/
validateCloudPcDevice?: boolean;
/**
* Intune Enrollment Validation: the last accepting gate — admits any device of the tenant's
* Intune inventory, matched by the Intune device id in the agent's MDM certificate, with no
* pre-registration. Requires the optional DeviceManagementManagedDevices.Read.All add-on
* ("IntuneDeviceBinding" in Optional Graph capabilities).
*/
validateIntuneDeviceBinding?: boolean;
allowInsecureAgentRequests?: boolean;
/**
@@ -8,7 +8,7 @@ import { useAdminMode } from "./useAdminMode";
* Global-Admin view is switched on (which demo mode forces off — see lib/demoMode.ts).
*
* Use this for operator-only surfaces that sit INSIDE an otherwise tenant-scoped page — the
* platform-bot Telegram provider, the cert-device-binding toggle, the retention escape hatch, the
* platform-bot Telegram provider, the retention escape hatch, the
* backend/portal build blocks. Turning the Global-Admin view off then yields a view that is
* genuinely indistinguishable from a tenant admin's, which is what live demos need.
*
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { saveAdminConfigChanges } from "@/lib/adminConfigSave";
import { changedAdminConfigFields, type AdminConfiguration } from "@/types/adminConfig";
// D-285: a save sends only the fields the page changed. The page's copy of everything else — the
// release pipeline's agent hashes above all — must never travel, or a page loaded before a release
// reverts it (2026-09-25).
const loaded = {
partitionKey: "GlobalConfig",
rowKey: "config",
enforceClientAppBinding: false,
mcpClientRegistrationEnabled: true,
globalRateLimitRequestsPerMinute: 100,
latestAgentV2Version: "2.0.1464",
latestAgentV2ExeSha256: "3cd3a3f1",
platformStatsBlobSasUrl: undefined,
excessiveEventAutoActionMode: undefined,
} as unknown as AdminConfiguration;
describe("changedAdminConfigFields", () => {
it("returns only the toggled field, never the stale agent hashes", () => {
const edited = { ...loaded, enforceClientAppBinding: true };
expect(changedAdminConfigFields(loaded, edited)).toEqual({ enforceClientAppBinding: true });
});
it("treats undefined and null as the same unset value", () => {
const edited = { ...loaded, platformStatsBlobSasUrl: null } as unknown as AdminConfiguration;
expect(changedAdminConfigFields(loaded, edited)).toEqual({});
});
it("compares the wire form, so an unset mode equal to the server default is no change", () => {
const edited = { ...loaded, excessiveEventAutoActionMode: "Off" } as AdminConfiguration;
expect(changedAdminConfigFields(loaded, edited)).toEqual({});
});
it("sends a cleared string as its new value", () => {
const withUrl = { ...loaded, platformStatsBlobSasUrl: "https://example.test/sas" } as AdminConfiguration;
const edited = { ...withUrl, platformStatsBlobSasUrl: "" } as AdminConfiguration;
expect(changedAdminConfigFields(withUrl, edited)).toEqual({ platformStatsBlobSasUrl: "" });
});
});
describe("saveAdminConfigChanges", () => {
it("sends no request when nothing changed", async () => {
const getAccessToken = vi.fn(async () => "token");
const fetchSpy = vi.spyOn(globalThis, "fetch");
await expect(saveAdminConfigChanges(loaded, { ...loaded }, getAccessToken)).resolves.toBeNull();
expect(getAccessToken).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
});
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { DEVICE_VALIDATION_FIELDS, addOnGranted, hasAnyDeviceValidation, INTUNE_ENROLLMENT_FEATURE } from "../deviceValidation";
import type { GetGraphPermissionsStatusResponse } from "@/utils/wire-types.generated";
describe("DEVICE_VALIDATION_FIELDS", () => {
it("comes from the manifest and covers every validation method", () => {
// Independent list: a validation method missing here would make its tenants look "not ready"
// and trigger the "agent ingestion is blocked" banner although agents are accepted.
expect([...DEVICE_VALIDATION_FIELDS].sort()).toEqual([
"validateAutopilotDevice",
"validateCloudPcDevice",
"validateCorporateIdentifier",
"validateDeviceAssociation",
"validateIntuneDeviceBinding",
]);
});
});
describe("hasAnyDeviceValidation", () => {
it("is true when any single method is on", () => {
for (const field of DEVICE_VALIDATION_FIELDS) {
expect(hasAnyDeviceValidation({ [field]: true }), field).toBe(true);
}
});
it("is false when every method is off or absent", () => {
expect(hasAnyDeviceValidation({})).toBe(false);
expect(hasAnyDeviceValidation({ validateAutopilotDevice: false, validateIntuneDeviceBinding: false })).toBe(false);
// Unrelated switches do not count.
expect(hasAnyDeviceValidation({ allowInsecureAgentRequests: true })).toBe(false);
});
});
describe("addOnGranted", () => {
const status = (overrides: Partial<GetGraphPermissionsStatusResponse> = {}): GetGraphPermissionsStatusResponse => ({
clientId: "aaaaaaaa-0000-0000-0000-000000000001",
isTransient: false,
grantedRoles: [],
features: [
{ name: INTUNE_ENROLLMENT_FEATURE, granted: true, requiredPermissions: ["DeviceManagementManagedDevices.Read.All"] },
{ name: "W365CloudPcValidation", granted: false, requiredPermissions: ["CloudPC.Read.All"] },
],
...overrides,
});
it("reads the feature's verdict", () => {
expect(addOnGranted(status(), INTUNE_ENROLLMENT_FEATURE)).toBe(true);
expect(addOnGranted(status(), "W365CloudPcValidation")).toBe(false);
});
it("is unknown while loading, on a transient snapshot, or for an unlisted feature", () => {
expect(addOnGranted(null, INTUNE_ENROLLMENT_FEATURE)).toBeNull();
expect(addOnGranted(status({ isTransient: true }), INTUNE_ENROLLMENT_FEATURE)).toBeNull();
expect(addOnGranted(status(), "Unknown")).toBeNull();
expect(addOnGranted(status({ features: [{ name: INTUNE_ENROLLMENT_FEATURE, requiredPermissions: [] }] }), INTUNE_ENROLLMENT_FEATURE)).toBeNull();
});
});
@@ -0,0 +1,36 @@
import { api } from "@/lib/api";
import { fetchJson, jsonBody, type GetAccessToken } from "@/lib/apiClient";
import { changedAdminConfigFields, fromWireAdminConfiguration, type AdminConfiguration } from "@/types/adminConfig";
import type {
AdminConfiguration as WireAdminConfiguration,
PatchAdminConfigurationRequest,
UpdateAdminConfigurationResponse,
} from "@/utils/wire-types.generated";
/**
* Saves the admin configuration by sending only what changed (PATCH global/config, D-285).
* `loaded` is the configuration as the page received it, `edited` the same object with the
* section's edits applied. Returns the stored configuration after the write, or null when nothing
* changed and no request was sent.
*/
export async function saveAdminConfigChanges(
loaded: AdminConfiguration,
edited: AdminConfiguration,
getAccessToken: GetAccessToken,
): Promise<AdminConfiguration | null> {
const fields = changedAdminConfigFields(loaded, edited);
if (Object.keys(fields).length === 0) return null;
return patchAdminConfigFields(fields, getAccessToken);
}
/** Sends the given fields as they are — for a caller that changes one known field without a loaded page state. */
export async function patchAdminConfigFields(
fields: Partial<WireAdminConfiguration>,
getAccessToken: GetAccessToken,
): Promise<AdminConfiguration> {
const result = await fetchJson<UpdateAdminConfigurationResponse>(api.globalConfig.update(), getAccessToken, {
method: "PATCH",
body: jsonBody<PatchAdminConfigurationRequest>({ fields }),
});
return fromWireAdminConfiguration(result.config);
}
+2
View File
@@ -192,6 +192,8 @@ export const api = {
// ── Global Config (global admin) ──────────────────────────────────────────
globalConfig: {
get: () => `${API_BASE_URL}/api/global/config`,
/** PATCH with only the changed fields (D-285) — there is no full-model PUT. */
update: () => `${API_BASE_URL}/api/global/config`,
/** Sends a test notification to one saved platform (ops) channel. */
testOpsChannel: () => `${API_BASE_URL}/api/global/config/test-ops-channel`,
// NOTE: there is deliberately no per-tenant variant — /api/global/config/{tenantId} does
@@ -0,0 +1,26 @@
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
import type { GetGraphPermissionsStatusResponse } from "@/utils/wire-types.generated";
/**
* Every `validate*` flag of the tenant configuration, read from the shared manifest. The agent
* gate accepts traffic when any of them is on (TenantConfiguration.HasAnyDeviceValidation in the
* backend, pinned to "every Validate* flag" by its own test), so a new validation method is
* picked up here without a code change.
*/
export const DEVICE_VALIDATION_FIELDS: readonly string[] =
SHARED_MANIFEST.tenantConfiguration.fields.filter((field: string) => /^validate[A-Z]/.test(field));
/** True when at least one device-validation method is enabled on this configuration. */
export function hasAnyDeviceValidation(config: object): boolean {
const values = config as Record<string, unknown>;
return DEVICE_VALIDATION_FIELDS.some((field) => values[field] === true);
}
/** Grant-script feature backing Intune Enrollment Validation (GraphFeatureCatalog.FeatureIntuneDeviceBinding). */
export const INTUNE_ENROLLMENT_FEATURE = "IntuneDeviceBinding";
/** Granted verdict of one add-on feature; null while unknown (not loaded, failed, or transient snapshot). */
export function addOnGranted(status: GetGraphPermissionsStatusResponse | null, feature: string): boolean | null {
if (!status || status.isTransient) return null;
return status.features.find((f) => f.name === feature)?.granted ?? null;
}
@@ -31,6 +31,28 @@ export function toWireAdminConfiguration(config: AdminConfiguration): WireAdminC
return { ...config, excessiveEventAutoActionMode: config.excessiveEventAutoActionMode ?? "Off" };
}
/**
* The wire fields whose value differs between the configuration as loaded and as edited — the only
* ones a save sends (PATCH global/config, D-285). The backend re-reads the row and writes just these,
* so a page loaded before another writer (the agent release pipeline, a second operator, a sync job)
* can never revert that writer's fields. Compared on the wire form; undefined and null are the same
* unset value.
*/
export function changedAdminConfigFields(
loaded: AdminConfiguration,
edited: AdminConfiguration,
): Partial<WireAdminConfiguration> {
const before = toWireAdminConfiguration(loaded) as unknown as Record<string, unknown>;
const after = toWireAdminConfiguration(edited) as unknown as Record<string, unknown>;
const changed: Record<string, unknown> = {};
for (const key of Object.keys(after)) {
if (JSON.stringify(after[key] ?? null) !== JSON.stringify(before[key] ?? null)) {
changed[key] = after[key] ?? null;
}
}
return changed as Partial<WireAdminConfiguration>;
}
/** One parsed entry of AdminConfiguration.opsAlertRulesJson (client-side view). */
export interface OpsAlertRule {
eventType: string;
@@ -14239,6 +14239,23 @@
}
]
},
"PatchAdminConfigurationRequest": {
"kind": "object",
"doc": "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.",
"fields": [
{
"name": "fields",
"type": {
"kind": "map",
"value": {
"kind": "primitive",
"name": "unknown"
}
},
"optional": true
}
]
},
"PatchTenantConfigurationFieldsRequest": {
"kind": "object",
"doc": "Body of PATCH config/{tenantId}/fields: { \"fields\": { <fieldName>: <value>, ... }, \"reason\"?: \"...\" } with at least one field. Values take the field's own JSON type (TenantConfiguration).",
@@ -24121,7 +24138,7 @@
},
"UpdateAdminConfigurationResponse": {
"kind": "object",
"doc": "Response of PUT global/config: acknowledgement plus the stored admin configuration.",
"doc": "Response of PATCH global/config: acknowledgement plus the stored admin configuration.",
"fields": [
{
"name": "success",
@@ -3029,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>;
@@ -5291,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;