diff --git a/internal b/internal index fd628bd2..3c6c340d 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit fd628bd2672f47e06853afe668967a64d59f400d +Subproject commit 3c6c340db92708712fdf7d5cb7e403265ce52876 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AgentConfigResolverTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AgentConfigResolverTests.cs new file mode 100644 index 00000000..8ca59792 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AgentConfigResolverTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the derivation shared by the agent channel (GET agent/config) and the Global-Admin +/// report route (GET config/{tenantId}/effective-agent-config): every rule the Tenant Config +/// Report used to re-implement in the web now has exactly one implementation, and these +/// facts are its contract. +/// +public class AgentConfigResolverTests +{ + private const string TenantId = "11111111-2222-3333-4444-555555555555"; + private static readonly DateTime Now = new(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc); + + private static TenantConfiguration Tenant(Action? mutate = null) + { + var c = new TenantConfiguration { TenantId = TenantId, DomainName = "contoso.example", UpdatedBy = "ga@operator.example" }; + mutate?.Invoke(c); + return c; + } + + private static AdminConfiguration Admin(Action? mutate = null) + { + var a = new AdminConfiguration(); + mutate?.Invoke(a); + return a; + } + + private static AgentConfigResponse Build(TenantConfiguration tenant, AdminConfiguration? admin = null, int agentMajor = AgentConfigResolver.CurrentAgentMajor) + => AgentConfigResolver.Build(tenant, admin ?? Admin(), new List(), new List(), agentMajor, Now).Response; + + [Fact] + public void Fresh_tenant_gets_the_agent_defaults() + { + var r = Build(Tenant()); + + Assert.Equal(AgentConfigResolver.ConfigVersion, r.ConfigVersion); + Assert.Equal(AutopilotMonitor.Shared.Constants.DefaultUploadIntervalSeconds, r.UploadIntervalSeconds); + Assert.Equal(10, r.UploadIntervalSeconds); // the report showed 30 for a month — pinned + Assert.True(r.SelfDestructOnComplete); + Assert.False(r.KeepLogFile); + Assert.True(r.EnableGeoLocation); + Assert.Equal(5, r.MaxAuthFailures); + Assert.Equal(0, r.AuthFailureTimeoutMinutes); + Assert.Equal("Info", r.LogLevel); + Assert.Equal(10, r.RebootDelaySeconds); + Assert.Equal(60, r.EnrollmentSummaryTimeoutSeconds); + Assert.Equal(120, r.EnrollmentSummaryLaunchRetrySeconds); + Assert.Equal(100, r.MaxBatchSize); + Assert.Equal("time.windows.com", r.NtpServer); + Assert.Equal("Off", r.DiagnosticsUploadMode); + Assert.False(r.DiagnosticsUploadEnabled); + Assert.False(r.UnrestrictedMode); + Assert.False(r.DeviceBlocked); + Assert.False(r.DeviceKillSignal); + Assert.Null(r.UnblockAt); + Assert.Null(r.MigrateToApiBaseUrl); + + Assert.Equal(360, r.Collectors.AgentMaxLifetimeMinutes); + Assert.Equal(15, r.Collectors.CollectorIdleTimeoutMinutes); + Assert.Equal(10, r.Collectors.DesktopDetectorNoCandidateTimeoutMinutes); + Assert.Equal(new[] { 100, 1005, 1010 }, r.Collectors.ModernDeploymentHarmlessEventIds); + + Assert.True(r.Analyzers.EnableLocalAdminAnalyzer); + Assert.Empty(r.Analyzers.LocalAdminAllowedAccounts); + Assert.False(r.Analyzers.EnableSoftwareInventoryAnalyzer); + Assert.True(r.Analyzers.EnableIntegrityBypassAnalyzer); + Assert.False(r.Analyzers.EnableRealmJoinWatcher); + Assert.False(r.Analyzers.KeepAwakeDuringUserEsp); + Assert.True(r.Analyzers.EnableConsoleBypassDetection); + } + + [Theory] + [InlineData(null, "CustomerSas", "Off", false)] + [InlineData(null, "Hosted", "Off", true)] // hosted destination needs no SAS + [InlineData("https://acct.blob.core.windows.net/c?sv=1", "CustomerSas", "Off", true)] // SAS enables regardless of mode + public void Diagnostics_upload_enabled_follows_sas_or_hosted_not_the_mode(string? sas, string destination, string mode, bool expected) + { + var r = Build(Tenant(t => + { + t.DiagnosticsBlobSasUrl = sas!; + t.DiagnosticsUploadDestination = destination; + t.DiagnosticsUploadMode = mode; + })); + Assert.Equal(expected, r.DiagnosticsUploadEnabled); + } + + [Theory] + [InlineData("free", true, true, false)] // Community: entitlement missing + [InlineData("pro", false, true, false)] // GA gate off + [InlineData("pro", true, false, false)] // tenant toggle off + [InlineData("pro", true, true, true)] + public void Unrestricted_mode_needs_entitlement_and_gate_and_toggle(string planTier, bool gaGate, bool toggle, bool expected) + { + var r = Build(Tenant(t => + { + t.PlanTier = planTier; + t.UnrestrictedModeEnabled = gaGate; + t.UnrestrictedMode = toggle; + })); + Assert.Equal(expected, r.UnrestrictedMode); + } + + [Fact] + public void Operator_knobs_come_from_the_admin_configuration() + { + var admin = Admin(a => + { + a.CollectorIdleTimeoutMinutes = 25; + a.DesktopDetectorNoCandidateTimeoutMinutes = 7; + a.AllowAgentDowngrade = true; + a.ModernDeploymentHarmlessEventIdsJson = "[100, 42]"; + a.LatestAgentV2Sha256 = "zip-v2"; + a.LatestAgentV2ExeSha256 = "exe-v2"; + }); + + var r = Build(Tenant(), admin); + + Assert.Equal(25, r.Collectors.CollectorIdleTimeoutMinutes); + Assert.Equal(7, r.Collectors.DesktopDetectorNoCandidateTimeoutMinutes); + Assert.True(r.AllowAgentDowngrade); + Assert.Equal(new[] { 100, 42 }, r.Collectors.ModernDeploymentHarmlessEventIds); + Assert.Equal("zip-v2", r.LatestAgentSha256); + Assert.Equal("exe-v2", r.LatestAgentExeSha256); + } + + [Fact] + public void Legacy_major_gets_empty_hashes_the_current_major_gets_v2() + { + var admin = Admin(a => { a.LatestAgentV2Sha256 = "zip-v2"; a.LatestAgentV2ExeSha256 = "exe-v2"; }); + + var legacy = Build(Tenant(), admin, agentMajor: 1); + Assert.Equal(string.Empty, legacy.LatestAgentSha256); + + var current = Build(Tenant(), admin, agentMajor: AgentConfigResolver.CurrentAgentMajor); + Assert.Equal("zip-v2", current.LatestAgentSha256); + } + + [Fact] + public void Tenant_overrides_win_over_defaults() + { + var r = Build(Tenant(t => + { + t.MaxBatchSize = 25; + t.LogLevel = "Debug"; + t.NtpServer = ""; + t.LocalAdminAllowedAccountsJson = "[\"Admin\",\"Helpdesk\"]"; + t.AgentMaxLifetimeMinutes = 120; + t.EnableConsoleBypassDetection = false; + })); + + Assert.Equal(25, r.MaxBatchSize); + Assert.Equal("Debug", r.LogLevel); + Assert.Equal("time.windows.com", r.NtpServer); // empty string falls back like null + Assert.Equal(new[] { "Admin", "Helpdesk" }, r.Analyzers.LocalAdminAllowedAccounts); + Assert.Equal(120, r.Collectors.AgentMaxLifetimeMinutes); + Assert.False(r.Analyzers.EnableConsoleBypassDetection); + } + + [Fact] + public void Migration_target_is_served_when_valid_and_reported_when_rejected() + { + const string Us = "https://autopilotmonitor-api-us.azurewebsites.net"; + var served = AgentConfigResolver.Build(Tenant(), Admin(a => a.AgentMigrateApiBaseUrl = Us), + new List(), new List(), AgentConfigResolver.CurrentAgentMajor, Now); + Assert.Equal(Us, served.Response.MigrateToApiBaseUrl); + Assert.Null(served.RejectedMigrateCandidate); + + var rejected = AgentConfigResolver.Build(Tenant(), Admin(a => a.AgentMigrateApiBaseUrl = "https://evil.example"), + new List(), new List(), AgentConfigResolver.CurrentAgentMajor, Now); + Assert.Null(rejected.Response.MigrateToApiBaseUrl); + Assert.Equal("https://evil.example", rejected.RejectedMigrateCandidate); + } + + [Fact] + public void Catalogs_pass_through_unchanged() + { + var rules = new List { new() { RuleId = "GATHER-TEST-001" } }; + var patterns = new List { new() { PatternId = "IME-TEST-001" } }; + var r = AgentConfigResolver.Build(Tenant(), Admin(), rules, patterns, AgentConfigResolver.CurrentAgentMajor, Now).Response; + Assert.Same(rules, r.GatherRules); + Assert.Same(patterns, r.ImeLogPatterns); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AgentEndpointMigrationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AgentEndpointMigrationTests.cs index e87480b3..7266e3c2 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AgentEndpointMigrationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AgentEndpointMigrationTests.cs @@ -1,4 +1,4 @@ -using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Services; using Xunit; @@ -8,7 +8,7 @@ namespace AutopilotMonitor.Functions.Tests; /// /// Pins the endpoint-migration serving contract: the shared allowlist rules /// (, used verbatim by the agent too) and the -/// backend-side per-tenant resolution ( — +/// backend-side per-tenant resolution ( — /// tenant override wins, empty override pins, invalid values are never served). /// public class AgentEndpointMigrationTests @@ -75,7 +75,7 @@ public class AgentEndpointMigrationTests { var config = new AdminConfiguration(); - Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected)); + Assert.Null(AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out var rejected)); Assert.Null(rejected); } @@ -84,8 +84,8 @@ public class AgentEndpointMigrationTests { var config = new AdminConfiguration { AgentMigrateApiBaseUrl = UsUrl }; - Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _)); - Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _)); + Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _)); + Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantB, out _)); } [Fact] @@ -97,8 +97,8 @@ public class AgentEndpointMigrationTests AgentMigrateTenantOverridesJson = $"{{\"{TenantA}\": \"{EuUrl}\"}}" }; - Assert.Equal(EuUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _)); - Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _)); + Assert.Equal(EuUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _)); + Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantB, out _)); } [Fact] @@ -110,9 +110,9 @@ public class AgentEndpointMigrationTests AgentMigrateTenantOverridesJson = $"{{\"{TenantA}\": \"\"}}" }; - Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected)); + Assert.Null(AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out var rejected)); Assert.Null(rejected); // pinned, not rejected - Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _)); + Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantB, out _)); } [Fact] @@ -123,7 +123,7 @@ public class AgentEndpointMigrationTests AgentMigrateTenantOverridesJson = $"{{\"{TenantA.ToUpperInvariant()}\": \"{UsUrl}\"}}" }; - Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _)); + Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _)); } [Fact] @@ -131,7 +131,7 @@ public class AgentEndpointMigrationTests { var config = new AdminConfiguration { AgentMigrateApiBaseUrl = "https://attacker.example.com" }; - Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected)); + Assert.Null(AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out var rejected)); Assert.Equal("https://attacker.example.com", rejected); } @@ -144,7 +144,7 @@ public class AgentEndpointMigrationTests AgentMigrateTenantOverridesJson = "{not json" }; - Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _)); + Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _)); } [Fact] @@ -155,6 +155,6 @@ public class AgentEndpointMigrationTests AgentMigrateApiBaseUrl = "https://AutopilotMonitor-API-US.azurewebsites.net/" }; - Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _)); + Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _)); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CrossTenantAccessTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CrossTenantAccessTests.cs index 93f85955..dedf7da7 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/CrossTenantAccessTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CrossTenantAccessTests.cs @@ -182,6 +182,7 @@ public class CrossTenantAccessTests [Theory] [InlineData("PATCH", "/api/config/{0}/plan")] + [InlineData("GET", "/api/config/{0}/effective-agent-config")] [InlineData("POST", "/api/preview/whitelist/{0}")] [InlineData("DELETE", "/api/preview/whitelist/{0}")] [InlineData("POST", "/api/preview/send-welcome-email/{0}")] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetAgentConfigFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetAgentConfigFunctionTests.cs index 3d086d75..b8ba290a 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GetAgentConfigFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetAgentConfigFunctionTests.cs @@ -1,10 +1,10 @@ -using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Functions.Tests; /// -/// Tests for — the pure decision logic that picks +/// Tests for — the pure decision logic that picks /// the per-line hash-oracle field set based on the X-Agent-Version header value. /// public class GetAgentConfigFunctionTests @@ -29,7 +29,7 @@ public class GetAgentConfigFunctionTests [InlineData("vNEXT", 1)] public void ParseAgentMajor_ExtractsMajor(string? agentVersion, int expectedMajor) { - var actual = GetAgentConfigFunction.ParseAgentMajor(agentVersion); + var actual = AgentConfigResolver.ParseAgentMajor(agentVersion); Assert.Equal(expectedMajor, actual); } @@ -49,7 +49,7 @@ public class GetAgentConfigFunctionTests P(@"C:\Windows\SetupDiag\*.log", "tenant"), }; - var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(global, tenant); + var merged = AgentConfigResolver.MergeDiagnosticsLogPaths(global, tenant); Assert.Equal(new[] { @@ -63,7 +63,7 @@ public class GetAgentConfigFunctionTests [Fact] public void MergeDiagnosticsLogPaths_DropsBlankEntries() { - var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths( + var merged = AgentConfigResolver.MergeDiagnosticsLogPaths( new[] { P(""), P(" "), P(@"C:\Install\Log\*.log") }, new DiagnosticsLogPath[] { null!, P(@"C:\Install\Log\*.log") }); @@ -90,7 +90,7 @@ public class GetAgentConfigFunctionTests public void ResolveDiagnosticsUploadEnabled_GatesOnSasOrHosted( string? diagnosticsBlobSasUrl, string? destination, bool expected) { - var actual = GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(diagnosticsBlobSasUrl, destination); + var actual = AgentConfigResolver.ResolveDiagnosticsUploadEnabled(diagnosticsBlobSasUrl, destination); Assert.Equal(expected, actual); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs index 438b80b1..e694608c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs @@ -9,7 +9,8 @@ namespace AutopilotMonitor.Functions.Tests; /// is a new private parser copy — the state the 2026-09-04 API audit found six times over for /// days alone, each with its own default, cap and out-of-range rule. The baseline lists /// the sites that parse something other than a query parameter (a header, a stored cell, a -/// version string); it only shrinks. +/// version string); it only shrinks. The X-Agent-Version major parse moved to +/// Services/AgentConfigResolver.cs (outside the scanned roots) with D-216. /// public class QueryParamGuardTests { @@ -19,7 +20,6 @@ public class QueryParamGuardTests /// Non-query parses, reviewed: file → why it is allowed. private static readonly IReadOnlyDictionary Baseline = new Dictionary(StringComparer.Ordinal) { - ["Functions/Config/GetAgentConfigFunction.cs"] = "major of the X-Agent-Version header", ["Functions/Ingest/IngestTelemetryFunction.cs"] = "X-Send-Time-Utc header (RoundtripKind, deliberately not a query instant)", ["Functions/Raw/QueryRawEventsFunction.cs"] = "Severity / Sequence cells of raw table rows", ["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "se= expiry inside the tenant's stored SAS URL", diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs index 337fdb8c..872b2dae 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs @@ -60,13 +60,26 @@ public sealed class SharedManifestParityTests // v2: adds the "types" section (full wire-type graph) and drops v1's // "sessionSummary" (it had no TS consumer; SessionSummary now rides in "types"). ["schemaVersion"] = 2, + // "defaults" = the value of every wire field on a freshly constructed instance (the C# + // initializer): the web's "custom" highlight compares stored values against these + // instead of a hand-copied map. Nested objects appear as null — they have their own section. ["adminConfiguration"] = new Dictionary { ["fields"] = WireFieldNames(typeof(AdminConfiguration)), + ["defaults"] = WireDefaults(typeof(AdminConfiguration)), }, ["tenantConfiguration"] = new Dictionary { ["fields"] = WireFieldNames(typeof(TenantConfiguration)), + ["defaults"] = WireDefaults(typeof(TenantConfiguration)), + }, + // What an agent receives (GET agent/config) at its class defaults — the runtime column of + // the Tenant Config Report highlights every effective value that differs from these. + ["agentConfig"] = new Dictionary + { + ["responseDefaults"] = WireDefaults(typeof(AgentConfigResponse)), + ["collectorDefaults"] = WireDefaults(typeof(CollectorConfiguration)), + ["analyzerDefaults"] = WireDefaults(typeof(AnalyzerConfiguration)), }, // Enum member order is load-bearing (append-only ordinals) — declaration order kept. ["sessionStatuses"] = Enum.GetNames(typeof(SessionStatus)), @@ -116,6 +129,29 @@ public sealed class SharedManifestParityTests private static string[] WireFieldNames(Type type) => WireProperties(type).Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)).ToArray(); + /// + /// Wire name → initializer value of a fresh instance. Scalars, strings, enums (by name), + /// DateTime (round-trip) and arrays/lists of those travel as-is; a nested object is null. + /// + private static Dictionary WireDefaults(Type type) + { + var instance = Activator.CreateInstance(type)!; + return WireProperties(type).ToDictionary( + p => JsonNamingPolicy.CamelCase.ConvertName(p.Name), + p => DefaultValue(p.GetValue(instance))); + } + + private static object? DefaultValue(object? value) => value switch + { + null => null, + string s => s, + bool or int or long or double or float or decimal => value, + Enum e => e.ToString(), + DateTime d => d.ToString("O"), + System.Collections.IEnumerable list => list.Cast().Select(DefaultValue).ToArray(), + _ => null, + }; + private static IEnumerable WireProperties(Type type) => type.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.GetMethod != null && p.GetIndexParameters().Length == 0); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index b652b859..1cadfcde 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -20,8 +20,7 @@ namespace AutopilotMonitor.Functions.Functions.Config private readonly ILogger _logger; private readonly TenantConfigurationService _configService; private readonly AdminConfigurationService _adminConfigService; - private readonly GatherRuleService _gatherRuleService; - private readonly ImeLogPatternService _imeLogPatternService; + private readonly AgentConfigResolver _resolver; private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; private readonly CorporateIdentifierValidator _corporateIdentifierValidator; @@ -35,8 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Config ILogger logger, TenantConfigurationService configService, AdminConfigurationService adminConfigService, - GatherRuleService gatherRuleService, - ImeLogPatternService imeLogPatternService, + AgentConfigResolver resolver, RateLimitService rateLimitService, AutopilotDeviceValidator autopilotDeviceValidator, CorporateIdentifierValidator corporateIdentifierValidator, @@ -49,8 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Config _logger = logger; _configService = configService; _adminConfigService = adminConfigService; - _gatherRuleService = gatherRuleService; - _imeLogPatternService = imeLogPatternService; + _resolver = resolver; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; _corporateIdentifierValidator = corporateIdentifierValidator; @@ -105,108 +102,8 @@ namespace AutopilotMonitor.Functions.Functions.Config } /// - /// Parses the major-version from an X-Agent-Version header value. - /// Accepts SemVer-ish strings like "2.0.114" or "2.0.114+abc123". - /// Missing/unparsable → returns 1 (backward-compat: very old agents may omit the - /// header). The V1 line is retired, so major 1 resolves to empty hashes via - /// GetAgentLine's default arm — legacy stragglers just skip their integrity check. - /// Deliberately NOT defaulting to 2: that would hand V2 hashes to V1 binaries and - /// could trigger the runtime_hash_mismatch force-update path against the wrong line. - /// - internal static int ParseAgentMajor(string? agentVersion) - { - if (string.IsNullOrWhiteSpace(agentVersion)) - return 1; - - var dot = agentVersion.IndexOf('.'); - var majorStr = dot > 0 ? agentVersion.Substring(0, dot) : agentVersion; - return int.TryParse(majorStr, out var major) ? major : 1; - } - - /// - /// Decides whether the agent should perform diagnostics uploads at all. - /// The CustomerSas destination is gated on a per-tenant SAS URL being present, but the - /// Hosted destination has no such URL (the platform owns the storage) — so gating purely - /// on the SAS URL silently disabled uploads for every Hosted-destination tenant. Enable - /// when either a customer SAS is configured OR the destination is Hosted. - /// - internal static bool ResolveDiagnosticsUploadEnabled(string? diagnosticsBlobSasUrl, string? destination) - { - return !string.IsNullOrEmpty(diagnosticsBlobSasUrl) - || string.Equals(destination, "Hosted", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Global ∪ tenant diagnostics paths: global first, order preserved, blank paths dropped, - /// duplicates (trimmed, case-insensitive) collapsed onto the first occurrence. The agent - /// keys ZIP entries on the path, so a path present in both lists would otherwise produce - /// duplicate entry names inside the archive. - /// - internal static List MergeDiagnosticsLogPaths( - IEnumerable global, - IEnumerable tenant) - { - return global.Concat(tenant) - .Where(p => !string.IsNullOrWhiteSpace(p?.Path)) - .GroupBy(p => p.Path.Trim(), StringComparer.OrdinalIgnoreCase) - .Select(g => g.First()) - .ToList(); - } - - /// - /// Returns the (ZipSha256, ExeSha256) pair appropriate for the calling agent. - /// Reads the X-Agent-Version header, parses the major, and dispatches to the - /// corresponding per-line field set on via - /// . Future V3 = no change here; - /// add a switch arm in GetAgentLine and a field set on AdminConfiguration. - /// - internal static (string ZipSha256, string ExeSha256) SelectAgentHashesForClient( - HttpRequestData req, - AutopilotMonitor.Shared.Models.AdminConfiguration adminConfig) - { - var agentVersion = req.Headers.Contains("X-Agent-Version") - ? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() - : null; - - var line = adminConfig.GetAgentLine(ParseAgentMajor(agentVersion)); - return (line.ZipSha256, line.ExeSha256); - } - - /// - /// Resolves the endpoint-migration target for a tenant: per-tenant override wins over - /// the global value; an override entry with an empty value pins the tenant (no - /// migration even while the global target is set — staged rollout). The winning value - /// is validated against ; - /// invalid values resolve to null (fail-safe: better to strand an agent on the old - /// backend than to serve a broken or non-allowlisted URL). - /// - internal static string? ResolveMigrateTarget( - AutopilotMonitor.Shared.Models.AdminConfiguration adminConfig, - string tenantId, - out string? rejectedCandidate) - { - rejectedCandidate = null; - string? candidate = adminConfig.AgentMigrateApiBaseUrl; - - var overrides = adminConfig.GetAgentMigrateTenantOverrides(); - if (!string.IsNullOrEmpty(tenantId) && overrides.TryGetValue(tenantId, out var tenantTarget)) - candidate = tenantTarget; // empty string = pinned, handled below - - if (string.IsNullOrWhiteSpace(candidate)) - return null; - - if (AutopilotMonitor.Shared.Services.AgentEndpointMigrationRules - .TryNormalizeTarget(candidate, out var normalized)) - { - return normalized; - } - - rejectedCandidate = candidate; - return null; - } - - /// - /// Core config logic: fetch tenant + admin config, gather rules, IME patterns. + /// Core config logic: kill verdict for this device, then the shared derivation + /// () with the device verdict applied on top. /// Called by both the cert-auth Run() method and the bootstrap wrapper. /// is the certificate identity from the cert Subject CN /// (cert-auth callers); the bootstrap wrapper has none and leaves it null. @@ -236,114 +133,30 @@ namespace AutopilotMonitor.Functions.Functions.Config intuneDeviceId: intuneDeviceId); DeviceIdentityBinding.Stamp(req, killVerdict.IdentityBinding); - // Get tenant configuration - var tenantConfig = await _configService.GetConfigurationAsync(tenantId); + // Select the per-line hash oracle from the X-Agent-Version header (parametric per major). + var resolution = await _resolver.ResolveAsync(tenantId, AgentConfigResolver.ParseAgentMajor(agentVersionHeader)); + var response = resolution.Response; - // Get global admin config for platform-wide policy settings - var adminConfig = await _adminConfigService.GetConfigurationAsync(); - - // Build collector configuration from tenant settings + global policy - var collectors = new CollectorConfiguration - { - EnablePerformanceCollector = tenantConfig.EnablePerformanceCollector, - PerformanceIntervalSeconds = tenantConfig.PerformanceCollectorIntervalSeconds, - CollectorIdleTimeoutMinutes = adminConfig.CollectorIdleTimeoutMinutes, - DesktopDetectorNoCandidateTimeoutMinutes = adminConfig.DesktopDetectorNoCandidateTimeoutMinutes, - HelloWaitTimeoutSeconds = tenantConfig.HelloWaitTimeoutSeconds, - AgentMaxLifetimeMinutes = tenantConfig.AgentMaxLifetimeMinutes ?? 360, - ModernDeploymentHarmlessEventIds = adminConfig.GetModernDeploymentHarmlessEventIds().ToArray() - }; - - // Active gather rules + active IME log patterns for this tenant. Independent reads, - // both served from per-instance catalog caches — fetch them concurrently. - var gatherRulesTask = _gatherRuleService.GetActiveRulesForTenantAsync(tenantId); - var imeLogPatternsTask = _imeLogPatternService.GetActivePatternsForTenantAsync(tenantId); - await Task.WhenAll(gatherRulesTask, imeLogPatternsTask); - var gatherRules = await gatherRulesTask; - var imeLogPatterns = await imeLogPatternsTask; - - // Merge global + tenant-specific diagnostics log paths (the built-in sections are - // compiled into the agent — DiagnosticsBuiltInSections — and never travel here) - var diagLogPaths = MergeDiagnosticsLogPaths( - adminConfig.GetDiagnosticsGlobalLogPaths(), tenantConfig.GetDiagnosticsLogPaths()); - - // Select per-line hash oracle from the X-Agent-Version header (parametric per major). - // The wire response keeps generic field names (LatestAgentSha256 / LatestAgentExeSha256) - // so agent code is unchanged across all major lines. - var (latestAgentSha256, latestAgentExeSha256) = SelectAgentHashesForClient(req, adminConfig); - - // Endpoint migration on the control channel: serve the (validated) re-home target - // so agents still bound to this backend's compiled-in URL move themselves to the - // new deployment at their next start. LogWarning (not Information) because worker - // logs below Warning never reach App Insights — this line is the delivery evidence - // during a migration window. - var migrateTarget = ResolveMigrateTarget(adminConfig, tenantId, out var rejectedMigrateCandidate); - if (migrateTarget != null) + // LogWarning (not Information) because worker logs below Warning never reach App + // Insights — this line is the delivery evidence during a migration window. + if (response.MigrateToApiBaseUrl != null) { _logger.LogWarning( "AgentMigrateServed: tenant={TenantId} serial={Serial} agentVersion={AgentVersion} target={Target}", - tenantId, serialNumberHeader, agentVersionHeader, migrateTarget); + tenantId, serialNumberHeader, agentVersionHeader, response.MigrateToApiBaseUrl); } - else if (rejectedMigrateCandidate != null) + else if (resolution.RejectedMigrateCandidate != null) { _logger.LogWarning( "AgentMigrateRejected: configured migration target failed validation and is NOT served. tenant={TenantId} candidate={Candidate}", - tenantId, rejectedMigrateCandidate); + tenantId, resolution.RejectedMigrateCandidate); } - var response = await req.OkAsync(new AgentConfigResponse - { - ConfigVersion = 40, // EnableDoGroupIdAutoSet (Delivery Optimization group ID from network fingerprint) - UploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds, - SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, - KeepLogFile = tenantConfig.KeepLogFile ?? false, - EnableGeoLocation = tenantConfig.EnableGeoLocation ?? true, - EnableImeMatchLog = tenantConfig.EnableImeMatchLog ?? false, - EnableGatherRuleDebugLog = tenantConfig.EnableGatherRuleDebugLog ?? false, - EnableEspContinueAnywayObservation = tenantConfig.EnableEspContinueAnywayObservation ?? false, - MaxAuthFailures = tenantConfig.MaxAuthFailures ?? 5, - AuthFailureTimeoutMinutes = tenantConfig.AuthFailureTimeoutMinutes ?? 0, - LogLevel = tenantConfig.LogLevel ?? "Info", - RebootOnComplete = tenantConfig.RebootOnComplete ?? false, - RebootDelaySeconds = tenantConfig.RebootDelaySeconds ?? 10, - ShowEnrollmentSummary = tenantConfig.ShowEnrollmentSummary ?? false, - EnrollmentSummaryTimeoutSeconds = tenantConfig.EnrollmentSummaryTimeoutSeconds ?? 60, - EnrollmentSummaryBrandingImageUrl = tenantConfig.EnrollmentSummaryBrandingImageUrl, - EnrollmentSummaryLaunchRetrySeconds = tenantConfig.EnrollmentSummaryLaunchRetrySeconds ?? 120, - MaxBatchSize = tenantConfig.MaxBatchSize ?? 100, - DiagnosticsUploadEnabled = ResolveDiagnosticsUploadEnabled( - tenantConfig.DiagnosticsBlobSasUrl, tenantConfig.DiagnosticsUploadDestination), - DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off", - DiagnosticsLogPaths = diagLogPaths, - Collectors = collectors, - Analyzers = new AnalyzerConfiguration - { - EnableLocalAdminAnalyzer = tenantConfig.EnableLocalAdminAnalyzer ?? true, - LocalAdminAllowedAccounts = tenantConfig.GetLocalAdminAllowedAccounts(), - EnableSoftwareInventoryAnalyzer = tenantConfig.EnableSoftwareInventoryAnalyzer ?? false, - EnableIntegrityBypassAnalyzer = tenantConfig.EnableIntegrityBypassAnalyzer ?? true, - EnableRealmJoinWatcher = tenantConfig.EnableRealmJoinWatcher ?? false, - KeepAwakeDuringUserEsp = tenantConfig.KeepAwakeDuringUserEsp ?? false, - EnableConsoleBypassDetection = tenantConfig.EnableConsoleBypassDetection ?? true - }, - LatestAgentSha256 = latestAgentSha256, - LatestAgentExeSha256 = latestAgentExeSha256, - AllowAgentDowngrade = adminConfig.AllowAgentDowngrade, - NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer, - EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false, - EnableDoGroupIdAutoSet = tenantConfig.EnableDoGroupIdAutoSet ?? false, - SendTraceEvents = tenantConfig.SendTraceEvents, - UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(tenantConfig, DateTime.UtcNow), - GatherRules = gatherRules, - ImeLogPatterns = imeLogPatterns, - WhiteGloveSealingPatternIds = adminConfig.GetWhiteGloveSealingPatternIds(), - DeviceBlocked = killVerdict.IsBlocked, - DeviceKillSignal = killVerdict.IsKill, - UnblockAt = killVerdict.UnblockAt, - MigrateToApiBaseUrl = migrateTarget, - }); + response.DeviceBlocked = killVerdict.IsBlocked; + response.DeviceKillSignal = killVerdict.IsKill; + response.UnblockAt = killVerdict.UnblockAt; - return response; + return await req.OkAsync(response); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetEffectiveAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetEffectiveAgentConfigFunction.cs new file mode 100644 index 00000000..4a9193e7 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetEffectiveAgentConfigFunction.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Config +{ + /// + /// Global-Admin view of what an agent of the tenant receives from GET agent/config right + /// now: the same derivation the agent channel uses, minus + /// the per-device kill verdict (no device here). Drives the runtime column of the Tenant + /// Config Report, so the report cannot drift from the backend rules. Read-only, no + /// side effects, no ops event. + /// + public class GetEffectiveAgentConfigFunction + { + private readonly ILogger _logger; + private readonly AgentConfigResolver _resolver; + + public GetEffectiveAgentConfigFunction( + ILogger logger, + AgentConfigResolver resolver) + { + _logger = logger; + _resolver = resolver; + } + + [Function("GetEffectiveAgentConfig")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/{tenantId}/effective-agent-config")] HttpRequestData req, + string tenantId) + { + try + { + // GlobalAdminOnly + RouteParam scoping enforced by PolicyEnforcementMiddleware. + var requestCtx = req.GetRequestContext(); + + // Optional ?agentVersion= selects the hash-oracle line like the agent's + // X-Agent-Version header would; absent → the current (V2) line, never the + // legacy fallback an absent header means on the agent channel. + var agentVersion = System.Web.HttpUtility.ParseQueryString(req.Url.Query)["agentVersion"]; + var agentMajor = string.IsNullOrWhiteSpace(agentVersion) + ? AgentConfigResolver.CurrentAgentMajor + : AgentConfigResolver.ParseAgentMajor(agentVersion); + + var resolution = await _resolver.ResolveAsync(requestCtx.TargetTenantId, agentMajor); + return await req.OkAsync(resolution.Response); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "GetEffectiveAgentConfig"); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 17f3614d..d86a8555 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -310,6 +310,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Shared agent-config derivation for the agent channel and the GA effective-config route. +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 561cd2f3..de48e55b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -458,6 +458,8 @@ public static class EndpointAccessPolicyCatalog // config writes remain a future policy decision. new("PATCH", "config/{tenantId}/fields", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), new("GET", "config/{tenantId}/backups", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), + // Operator view of the AgentConfigResponse an agent of the tenant receives (Tenant Config Report). + new("GET", "config/{tenantId}/effective-agent-config", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), new("POST", "config/{tenantId}/revert", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), // Field schema for the write surface above: tenant-independent reflection over the model // + deny-lists (no tenant data), but GA-only to match the surface it describes. Literal diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AgentConfigResolver.cs b/src/Backend/AutopilotMonitor.Functions/Services/AgentConfigResolver.cs new file mode 100644 index 00000000..99a7c11e --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/AgentConfigResolver.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Outcome of : the response an agent of the tenant + /// receives, plus the migration candidate that failed validation (null when none) so the + /// agent channel can log it as delivery evidence. + /// + public sealed class AgentConfigResolution + { + public AgentConfigResponse Response { get; } + public string? RejectedMigrateCandidate { get; } + + public AgentConfigResolution(AgentConfigResponse response, string? rejectedMigrateCandidate) + { + Response = response; + RejectedMigrateCandidate = rejectedMigrateCandidate; + } + } + + /// + /// The one place that derives an from a tenant's stored + /// configuration, the global admin configuration and the tenant's active rule catalogs. + /// Serves two callers with the same result: the cert-authenticated agent channel + /// (GetAgentConfigFunction, which adds the per-device kill verdict on top) and the + /// Global-Admin report route (GetEffectiveAgentConfigFunction), so the operator sees + /// exactly what an agent would receive. Every default applied here IS the agent-side + /// default; the class initializers of are the source + /// of the "custom" highlight in the web report via the shared manifest. + /// + public class AgentConfigResolver + { + /// Bumped when the wire shape gains a field the agent must understand. + public const int ConfigVersion = 40; // EnableDoGroupIdAutoSet (Delivery Optimization group ID from network fingerprint) + + /// + /// The agent line whose hash oracle is served when no X-Agent-Version is available + /// (operator report). V2 is the only wired line — see AdminConfiguration.GetAgentLine. + /// + public const int CurrentAgentMajor = 2; + + private readonly TenantConfigurationService _configService; + private readonly AdminConfigurationService _adminConfigService; + private readonly GatherRuleService _gatherRuleService; + private readonly ImeLogPatternService _imeLogPatternService; + + public AgentConfigResolver( + TenantConfigurationService configService, + AdminConfigurationService adminConfigService, + GatherRuleService gatherRuleService, + ImeLogPatternService imeLogPatternService) + { + _configService = configService; + _adminConfigService = adminConfigService; + _gatherRuleService = gatherRuleService; + _imeLogPatternService = imeLogPatternService; + } + + /// + /// Loads the four inputs and builds the response. Rule and pattern reads are independent + /// and served from per-instance catalog caches — fetched concurrently. + /// + public async Task ResolveAsync(string tenantId, int agentMajor) + { + var tenantConfig = await _configService.GetConfigurationAsync(tenantId); + var adminConfig = await _adminConfigService.GetConfigurationAsync(); + + var gatherRulesTask = _gatherRuleService.GetActiveRulesForTenantAsync(tenantId); + var imeLogPatternsTask = _imeLogPatternService.GetActivePatternsForTenantAsync(tenantId); + await Task.WhenAll(gatherRulesTask, imeLogPatternsTask); + + return Build(tenantConfig, adminConfig, await gatherRulesTask, await imeLogPatternsTask, agentMajor, DateTime.UtcNow); + } + + /// + /// Pure derivation: tenant config + admin config + catalogs → response. Device-specific + /// fields (DeviceBlocked, DeviceKillSignal, UnblockAt) stay at their defaults; the agent + /// channel sets them from its kill verdict. + /// + public static AgentConfigResolution Build( + TenantConfiguration tenantConfig, + AdminConfiguration adminConfig, + List gatherRules, + List imeLogPatterns, + int agentMajor, + DateTime nowUtc) + { + // Collector configuration from tenant settings + global policy + var collectors = new CollectorConfiguration + { + EnablePerformanceCollector = tenantConfig.EnablePerformanceCollector, + PerformanceIntervalSeconds = tenantConfig.PerformanceCollectorIntervalSeconds, + CollectorIdleTimeoutMinutes = adminConfig.CollectorIdleTimeoutMinutes, + DesktopDetectorNoCandidateTimeoutMinutes = adminConfig.DesktopDetectorNoCandidateTimeoutMinutes, + HelloWaitTimeoutSeconds = tenantConfig.HelloWaitTimeoutSeconds, + AgentMaxLifetimeMinutes = tenantConfig.AgentMaxLifetimeMinutes ?? 360, + ModernDeploymentHarmlessEventIds = adminConfig.GetModernDeploymentHarmlessEventIds().ToArray() + }; + + // Merge global + tenant-specific diagnostics log paths (the built-in sections are + // compiled into the agent — DiagnosticsBuiltInSections — and never travel here) + var diagLogPaths = MergeDiagnosticsLogPaths( + adminConfig.GetDiagnosticsGlobalLogPaths(), tenantConfig.GetDiagnosticsLogPaths()); + + // Per-line hash oracle (parametric per major). The wire response keeps generic field + // names (LatestAgentSha256 / LatestAgentExeSha256) so agent code is unchanged across lines. + var line = adminConfig.GetAgentLine(agentMajor); + + // Endpoint migration on the control channel: the (validated) re-home target, so agents + // still bound to this backend's compiled-in URL move themselves at their next start. + var migrateTarget = ResolveMigrateTarget(adminConfig, tenantConfig.TenantId, out var rejectedMigrateCandidate); + + var response = new AgentConfigResponse + { + ConfigVersion = ConfigVersion, + UploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds, + SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, + KeepLogFile = tenantConfig.KeepLogFile ?? false, + EnableGeoLocation = tenantConfig.EnableGeoLocation ?? true, + EnableImeMatchLog = tenantConfig.EnableImeMatchLog ?? false, + EnableGatherRuleDebugLog = tenantConfig.EnableGatherRuleDebugLog ?? false, + EnableEspContinueAnywayObservation = tenantConfig.EnableEspContinueAnywayObservation ?? false, + MaxAuthFailures = tenantConfig.MaxAuthFailures ?? 5, + AuthFailureTimeoutMinutes = tenantConfig.AuthFailureTimeoutMinutes ?? 0, + LogLevel = tenantConfig.LogLevel ?? "Info", + RebootOnComplete = tenantConfig.RebootOnComplete ?? false, + RebootDelaySeconds = tenantConfig.RebootDelaySeconds ?? 10, + ShowEnrollmentSummary = tenantConfig.ShowEnrollmentSummary ?? false, + EnrollmentSummaryTimeoutSeconds = tenantConfig.EnrollmentSummaryTimeoutSeconds ?? 60, + EnrollmentSummaryBrandingImageUrl = tenantConfig.EnrollmentSummaryBrandingImageUrl, + EnrollmentSummaryLaunchRetrySeconds = tenantConfig.EnrollmentSummaryLaunchRetrySeconds ?? 120, + MaxBatchSize = tenantConfig.MaxBatchSize ?? 100, + DiagnosticsUploadEnabled = ResolveDiagnosticsUploadEnabled( + tenantConfig.DiagnosticsBlobSasUrl, tenantConfig.DiagnosticsUploadDestination), + DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off", + DiagnosticsLogPaths = diagLogPaths, + Collectors = collectors, + Analyzers = new AnalyzerConfiguration + { + EnableLocalAdminAnalyzer = tenantConfig.EnableLocalAdminAnalyzer ?? true, + LocalAdminAllowedAccounts = tenantConfig.GetLocalAdminAllowedAccounts(), + EnableSoftwareInventoryAnalyzer = tenantConfig.EnableSoftwareInventoryAnalyzer ?? false, + EnableIntegrityBypassAnalyzer = tenantConfig.EnableIntegrityBypassAnalyzer ?? true, + EnableRealmJoinWatcher = tenantConfig.EnableRealmJoinWatcher ?? false, + KeepAwakeDuringUserEsp = tenantConfig.KeepAwakeDuringUserEsp ?? false, + EnableConsoleBypassDetection = tenantConfig.EnableConsoleBypassDetection ?? true + }, + LatestAgentSha256 = line.ZipSha256, + LatestAgentExeSha256 = line.ExeSha256, + AllowAgentDowngrade = adminConfig.AllowAgentDowngrade, + NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer, + EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false, + EnableDoGroupIdAutoSet = tenantConfig.EnableDoGroupIdAutoSet ?? false, + SendTraceEvents = tenantConfig.SendTraceEvents, + UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(tenantConfig, nowUtc), + GatherRules = gatherRules, + ImeLogPatterns = imeLogPatterns, + WhiteGloveSealingPatternIds = adminConfig.GetWhiteGloveSealingPatternIds(), + MigrateToApiBaseUrl = migrateTarget, + }; + + return new AgentConfigResolution(response, rejectedMigrateCandidate); + } + + /// + /// Parses the major-version from an X-Agent-Version header value. + /// Accepts SemVer-ish strings like "2.0.114" or "2.0.114+abc123". + /// Missing/unparsable → returns 1 (backward-compat: very old agents may omit the + /// header). The V1 line is retired, so major 1 resolves to empty hashes via + /// GetAgentLine's default arm — legacy stragglers just skip their integrity check. + /// Deliberately NOT defaulting to 2: that would hand V2 hashes to V1 binaries and + /// could trigger the runtime_hash_mismatch force-update path against the wrong line. + /// + internal static int ParseAgentMajor(string? agentVersion) + { + if (string.IsNullOrWhiteSpace(agentVersion)) + return 1; + + var dot = agentVersion.IndexOf('.'); + var majorStr = dot > 0 ? agentVersion.Substring(0, dot) : agentVersion; + return int.TryParse(majorStr, out var major) ? major : 1; + } + + /// + /// Decides whether the agent should perform diagnostics uploads at all. + /// The CustomerSas destination is gated on a per-tenant SAS URL being present, but the + /// Hosted destination has no such URL (the platform owns the storage) — so gating purely + /// on the SAS URL silently disabled uploads for every Hosted-destination tenant. Enable + /// when either a customer SAS is configured OR the destination is Hosted. + /// + internal static bool ResolveDiagnosticsUploadEnabled(string? diagnosticsBlobSasUrl, string? destination) + { + return !string.IsNullOrEmpty(diagnosticsBlobSasUrl) + || string.Equals(destination, "Hosted", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Global ∪ tenant diagnostics paths: global first, order preserved, blank paths dropped, + /// duplicates (trimmed, case-insensitive) collapsed onto the first occurrence. The agent + /// keys ZIP entries on the path, so a path present in both lists would otherwise produce + /// duplicate entry names inside the archive. + /// + internal static List MergeDiagnosticsLogPaths( + IEnumerable global, + IEnumerable tenant) + { + return global.Concat(tenant) + .Where(p => !string.IsNullOrWhiteSpace(p?.Path)) + .GroupBy(p => p.Path.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); + } + + /// + /// Resolves the endpoint-migration target for a tenant: per-tenant override wins over + /// the global value; an override entry with an empty value pins the tenant (no + /// migration even while the global target is set — staged rollout). The winning value + /// is validated against ; + /// invalid values resolve to null (fail-safe: better to strand an agent on the old + /// backend than to serve a broken or non-allowlisted URL). + /// + internal static string? ResolveMigrateTarget( + AdminConfiguration adminConfig, + string tenantId, + out string? rejectedCandidate) + { + rejectedCandidate = null; + + string? candidate = adminConfig.AgentMigrateApiBaseUrl; + var overrides = adminConfig.GetAgentMigrateTenantOverrides(); + if (!string.IsNullOrEmpty(tenantId) && overrides.TryGetValue(tenantId, out var tenantTarget)) + candidate = tenantTarget; // empty string = pinned, handled below + + if (string.IsNullOrWhiteSpace(candidate)) + return null; + + if (AutopilotMonitor.Shared.Services.AgentEndpointMigrationRules + .TryNormalizeTarget(candidate, out var normalized)) + { + return normalized; + } + + rejectedCandidate = candidate; + return null; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DiagnosticsUploadConfigChange.cs b/src/Backend/AutopilotMonitor.Functions/Services/DiagnosticsUploadConfigChange.cs index 44d54aa1..16d802e5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/DiagnosticsUploadConfigChange.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/DiagnosticsUploadConfigChange.cs @@ -1,5 +1,4 @@ using System; -using AutopilotMonitor.Functions.Functions.Config; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Functions.Services @@ -33,7 +32,7 @@ namespace AutopilotMonitor.Functions.Services { if (config == null) return false; return !string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase) - && GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled( + && AgentConfigResolver.ResolveDiagnosticsUploadEnabled( config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination); } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 809bde0b..36695f62 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -449,10 +449,11 @@ namespace AutopilotMonitor.Shared.Models public bool? EnableGeoLocation { get; set; } /// - /// NTP server address for time check during enrollment. - /// null = use agent default ("time.windows.com"). + /// NTP server address for time check during enrollment. The initializer is the same + /// value the table read maps a blank cell to and the agent falls back to, so a fresh + /// configuration, a stored one and the manifest default agree (D-216). /// - public string NtpServer { get; set; } = default!; + public string NtpServer { get; set; } = "time.windows.com"; /// /// Whether to automatically set the device timezone based on IP geolocation. diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx index 7752cbac..943d9ca1 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx @@ -5,17 +5,16 @@ import { useAuth } from '../../../../contexts/AuthContext'; import { api } from '@/lib/api'; import { apiErrorText, fetchJson } from "@/lib/apiClient"; import type { - AdminConfiguration, + AgentConfigResponse, TenantConfiguration, TenantFeatureFlagsResponse, } from "@/utils/wire-types.generated"; import { isNonDefault, RUNTIME_REPORT_SECTIONS, + RUNTIME_SECTIONS_COLLAPSED, TENANT_REPORT_SECTIONS, - UNRESOLVED, type RowKind, - type RuntimeContext, type RuntimeSource, } from './tenantConfigReportCatalog'; @@ -39,14 +38,13 @@ const WEBHOOK_PROVIDERS: Record = { const SOURCE_LABELS: Record = { tenant: 'tenant', - admin: 'global', - flags: 'entitlement', - constant: 'fixed', + global: 'global', + entitlement: 'entitlement', + fixed: 'fixed', }; function formatValue(val: unknown): string { if (val === null || val === undefined) return '—'; - if (val === UNRESOLVED) return 'n/a (source not loaded)'; if (typeof val === 'boolean') return val ? 'Yes' : 'No'; if (typeof val === 'string') return val || '—'; if (Array.isArray(val)) return val.length ? val.join(', ') : '—'; @@ -80,6 +78,8 @@ function formatByKind(kind: RowKind | undefined, value: unknown): string { return maskSasUrl(value as string); case 'secret': return value ? 'configured (hidden)' : '—'; + case 'count': + return Array.isArray(value) ? String(value.length) : '—'; default: return formatValue(value); } @@ -111,18 +111,35 @@ function ConfigRow({ label, display, highlight, sourceTag }: ConfigRowProps) { interface SectionProps { title: string; + /** Rendered as a closed
— for rows that are rarely relevant (agent class defaults). */ + collapsed?: boolean; children: React.ReactNode; } -function Section({ title, children }: SectionProps) { +function Section({ title, collapsed, children }: SectionProps) { + const heading =

{title}

; + const table = ( +
+ + {children} +
+
+ ); + if (collapsed) { + return ( +
+ + ▶ + {heading} + + {table} +
+ ); + } return (
-

{title}

-
- - {children} -
-
+
{heading}
+ {table}
); } @@ -195,7 +212,7 @@ export function SectionTenantConfigReport() { const [selectedTenantId, setSelectedTenantId] = useState(''); const [config, setConfig] = useState(null); const [flags, setFlags] = useState(null); - const [adminConfig, setAdminConfig] = useState(null); + const [effective, setEffective] = useState(null); const [loading, setLoading] = useState(false); const [loadingTenants, setLoadingTenants] = useState(true); const [error, setError] = useState(null); @@ -223,34 +240,34 @@ export function SectionTenantConfigReport() { fetchTenants(); }, [user?.isGlobalAdmin, getAccessToken]); - // Fetch config plus its two runtime sources for the selected tenant. The full config is - // the report; feature-flags (entitlement) and global config (operator knobs) are fail-soft — - // their rows show "n/a" and a notice instead of blocking the whole report. + // Fetch the stored config plus the two backend-resolved views of it. The stored config is + // the report; feature-flags (effective entitlement, header) and the effective agent config + // (runtime column) are fail-soft — a notice replaces them instead of blocking the page. const fetchConfig = useCallback(async () => { if (!selectedTenantId) return; try { setLoading(true); setError(null); setSideError(null); - const [configResult, flagsResult, adminResult] = await Promise.allSettled([ + const [configResult, flagsResult, effectiveResult] = await Promise.allSettled([ fetchJson(api.config.tenant(selectedTenantId), getAccessToken), fetchJson(api.config.featureFlags(selectedTenantId), getAccessToken), - fetchJson(api.globalConfig.get(), getAccessToken), + fetchJson(api.config.effectiveAgentConfig(selectedTenantId), getAccessToken), ]); if (configResult.status === 'rejected') throw configResult.reason; setConfig(configResult.value); setFlags(flagsResult.status === 'fulfilled' ? flagsResult.value : null); - setAdminConfig(adminResult.status === 'fulfilled' ? adminResult.value : null); + setEffective(effectiveResult.status === 'fulfilled' ? effectiveResult.value : null); const sideFailures = [ flagsResult.status === 'rejected' ? `feature flags: ${apiErrorText(flagsResult.reason)}` : null, - adminResult.status === 'rejected' ? `global config: ${apiErrorText(adminResult.reason)}` : null, + effectiveResult.status === 'rejected' ? `effective agent config: ${apiErrorText(effectiveResult.reason)}` : null, ].filter((s): s is string => s !== null); - setSideError(sideFailures.length ? `Runtime sources unavailable — ${sideFailures.join('; ')}` : null); + setSideError(sideFailures.length ? `Backend-resolved views unavailable — ${sideFailures.join('; ')}` : null); } catch (err) { setError(apiErrorText(err)); setConfig(null); setFlags(null); - setAdminConfig(null); + setEffective(null); } finally { setLoading(false); } @@ -263,8 +280,6 @@ export function SectionTenantConfigReport() { void run(); }, [fetchConfig]); - const runtimeCtx: RuntimeContext | null = config ? { config, adminConfig, flags } : null; - return (
{/* Header */} @@ -322,7 +337,7 @@ export function SectionTenantConfigReport() { )} {/* Config report */} - {!loading && config && runtimeCtx && ( + {!loading && config && ( <> {/* Tenant header card */}
@@ -405,14 +420,14 @@ export function SectionTenantConfigReport() { {TENANT_REPORT_SECTIONS.map(({ section, rows }) => (
{section === 'Webhooks' && } - {rows.map(({ key, row }) => { - const value = config[key as keyof TenantConfiguration]; + {rows.map(({ key, row, default: def }) => { + const value = config[key]; return ( ); })} @@ -424,21 +439,27 @@ export function SectionTenantConfigReport() {

Runtime Parameters (Agent)

- The effective values the agent receives at runtime, with defaults applied for unset fields. - The tag says where a value is resolved from: tenant config, global config, the tenant's entitlement, or a fixed backend constant. + What an agent of this tenant receives from the backend right now, resolved server-side. + The tag says which knob a value comes from: tenant config, global config, the tenant's entitlement, or a fixed agent default.

- {RUNTIME_REPORT_SECTIONS.map(({ section, rows }) => ( -
- {rows.map(({ key, row }) => { - const value = row.value(runtimeCtx); + {!effective && ( +
+ Effective agent configuration not available. +
+ )} + + {effective && RUNTIME_REPORT_SECTIONS.map(({ section, rows }) => ( +
+ {rows.map(({ key, row, value: read, default: def }) => { + const value = read(effective); return ( ); })} diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/__tests__/tenantConfigReportCatalog.test.ts b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/__tests__/tenantConfigReportCatalog.test.ts index e3692524..5e6126e1 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/__tests__/tenantConfigReportCatalog.test.ts +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/__tests__/tenantConfigReportCatalog.test.ts @@ -2,28 +2,29 @@ * Parity pins for the Tenant Config Report catalog. * * The catalog is compile-time exhaustive over the generated wire types; this file adds the - * runtime half against SHARED_MANIFEST.tenantConfiguration.fields (the C#-reflected field - * list, independent of the TS type) and pins the runtime resolution rules that mirror - * GetAgentConfigFunction.cs with test-local fixtures. + * runtime half against the shared manifest (the C#-reflected field lists and initializer + * defaults, independent of the TS types): every field is catalogued, every catalogued field + * has a default to compare against, and the value accessors read the effective agent config + * the backend returns. The derivation rules themselves are pinned in the backend + * (AgentConfigResolverTests) — nothing is re-computed here. */ import { describe, expect, it } from "vitest"; import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated"; -import type { AdminConfiguration, TenantConfiguration, TenantFeatureFlagsResponse } from "@/utils/wire-types.generated"; +import type { AgentConfigResponse } from "@/utils/wire-types.generated"; import { ANALYZER_FIELDS, COLLECTOR_FIELDS, - DEFAULT_UPLOAD_INTERVAL_SECONDS, isExcluded, isNonDefault, RUNTIME_FIELDS, RUNTIME_REPORT_SECTIONS, + RUNTIME_SECTIONS_COLLAPSED, TENANT_FIELDS, TENANT_REPORT_SECTIONS, - UNRESOLVED, - type RuntimeContext, } from "../tenantConfigReportCatalog"; const manifestFields = [...SHARED_MANIFEST.tenantConfiguration.fields] as string[]; +const manifestDefaults = SHARED_MANIFEST.tenantConfiguration.defaults as Record; describe("TENANT_FIELDS parity with the C# field manifest", () => { it("catalogues every wire field of TenantConfiguration (rendered or excluded with a reason)", () => { @@ -36,6 +37,11 @@ describe("TENANT_FIELDS parity with the C# field manifest", () => { expect(stale).toEqual([]); }); + it("the manifest carries an initializer default for every field", () => { + const withoutDefault = manifestFields.filter((f) => !(f in manifestDefaults)); + expect(withoutDefault).toEqual([]); + }); + it("gives every exclusion a reason and every row a non-empty label", () => { for (const [key, spec] of Object.entries(TENANT_FIELDS)) { if (isExcluded(spec)) expect(spec.excluded, key).not.toBe(""); @@ -44,7 +50,7 @@ describe("TENANT_FIELDS parity with the C# field manifest", () => { }); it("renders the fields the operator asked for by name", () => { - const rendered = new Set(TENANT_REPORT_SECTIONS.flatMap((s) => s.rows.map((r) => r.key))); + const rendered = new Set(TENANT_REPORT_SECTIONS.flatMap((s) => s.rows.map((r) => r.key as string))); for (const key of [ "payingCustomer", "maxDelegatedTenantsOverride", "mcpUsagePlanOverride", "managedByProTenantId", "trialConsumed", "proDowngradedUtc", "validateDeviceAssociation", "validateCloudPcDevice", @@ -56,154 +62,94 @@ describe("TENANT_FIELDS parity with the C# field manifest", () => { } }); - it("renders each field in exactly one section", () => { + it("renders each field in exactly one section, with its manifest default attached", () => { const seen = new Map(); for (const { rows } of TENANT_REPORT_SECTIONS) { - for (const { key } of rows) seen.set(key, (seen.get(key) ?? 0) + 1); + for (const { key, default: def } of rows) { + seen.set(key, (seen.get(key) ?? 0) + 1); + expect(def, key).toEqual(manifestDefaults[key]); + } } expect([...seen.entries()].filter(([, n]) => n !== 1)).toEqual([]); }); + + it("secrets, masked values and timestamps are informational (never marked custom)", () => { + for (const [key, spec] of Object.entries(TENANT_FIELDS)) { + if (isExcluded(spec)) continue; + if (spec.kind === "secret" || spec.kind === "masked" || spec.kind === "date") { + expect(spec.informational, key).toBe(true); + } + } + }); + + it("pins a few manifest defaults the report relies on", () => { + expect(manifestDefaults.planTier).toBe("free"); + expect(manifestDefaults.dataRetentionDays).toBe(90); + expect(manifestDefaults.diagnosticsUploadDestination).toBe("CustomerSas"); + expect(manifestDefaults.payingCustomer).toBe(false); + // The one string default the table read also applies to a blank cell — a stored + // "time.windows.com" must not read as custom. + expect(manifestDefaults.ntpServer).toBe("time.windows.com"); + }); }); -// ── Runtime rules ──────────────────────────────────────────────────────────── +// ── Runtime catalog ────────────────────────────────────────────────────────── -/** Minimal stored config: everything nullable unset, non-nullables at their C# initializers. */ -function storedConfig(overrides: Partial = {}): TenantConfiguration { - return { - tenantId: "00000000-0000-0000-0000-000000000001", - domainName: "contoso.example", - lastUpdated: "2026-09-06T00:00:00Z", - updatedBy: "ga@operator.example", - disabled: false, - mcpDisabled: false, - planTier: "free", - trialConsumed: false, - payingCustomer: false, - manufacturerWhitelist: "Dell*,HP*,Lenovo*,Microsoft Corporation", - modelWhitelist: "*", - validateAutopilotDevice: false, - validateCorporateIdentifier: false, - validateDeviceAssociation: false, - validateCloudPcDevice: false, - validateIntuneDeviceBinding: false, - allowInsecureAgentRequests: false, - dataRetentionDays: 90, - sessionTimeoutHours: 5, - sessionGraceHours: 0, - maxNdjsonPayloadSizeMB: 5, - enablePerformanceCollector: true, - performanceCollectorIntervalSeconds: 30, - helloWaitTimeoutSeconds: 30, - ntpServer: "time.windows.com", - logLevel: "", - enrollmentSummaryBrandingImageUrl: "", - localAdminAllowedAccountsJson: "", - bootstrapTokenEnabled: false, - unrestrictedModeEnabled: false, - unrestrictedMode: false, - entraAppRolesEnabled: false, - diagnosticsLogPathsJson: "", - diagnosticsBlobSasUrl: "", - diagnosticsUploadMode: "Off", - diagnosticsUploadDestination: "CustomerSas", - sendTraceEvents: true, - teamsWebhookUrl: "", - teamsNotifyOnSuccess: true, - teamsNotifyOnFailure: true, - teamsNotifyOnStart: false, - webhookProviderType: 0, - webhookUrl: "", - webhookNotifyOnSuccess: true, - webhookNotifyOnFailure: true, - webhookNotifyOnHardwareRejection: false, - webhookNotifyOnStart: false, - webhookCustomHeadersJson: "", - notificationChannelsJson: "", - slaNotifyOnSuccessRateBreach: false, - slaNotifyOnDurationBreach: false, - slaNotifyOnAppInstallBreach: false, - slaNotifyOnConsecutiveFailures: false, - slaConsecutiveFailureThreshold: 5, - ...overrides, - }; -} +const agentDefaults = SHARED_MANIFEST.agentConfig; -const adminConfig = { collectorIdleTimeoutMinutes: 25, desktopDetectorNoCandidateTimeoutMinutes: 7, allowAgentDowngrade: true, modernDeploymentHarmlessEventIdsJson: "[100, 42]" } as AdminConfiguration; -const flags = (unrestrictedMode: boolean) => ({ unrestrictedMode }) as TenantFeatureFlagsResponse; - -const ctx = (overrides: Partial = {}, extra: Partial = {}): RuntimeContext => ({ - config: storedConfig(overrides), - adminConfig: null, - flags: null, - ...extra, -}); - -const runtimeValue = (record: typeof RUNTIME_FIELDS | typeof COLLECTOR_FIELDS | typeof ANALYZER_FIELDS, key: string, c: RuntimeContext) => { - const spec = (record as Record)[key]; - if (!spec || isExcluded(spec)) throw new Error(`${key} is not a rendered runtime row`); - return spec.value(c); -}; - -describe("runtime rows mirror GetAgentConfigFunction", () => { - it("upload interval is the backend constant (10), not the old 30", () => { - expect(DEFAULT_UPLOAD_INTERVAL_SECONDS).toBe(10); - expect(runtimeValue(RUNTIME_FIELDS, "uploadIntervalSeconds", ctx())).toBe(10); - }); - - it("diagnostics upload is enabled by a SAS URL or the hosted destination, regardless of mode", () => { - expect(runtimeValue(RUNTIME_FIELDS, "diagnosticsUploadEnabled", ctx())).toBe(false); - expect(runtimeValue(RUNTIME_FIELDS, "diagnosticsUploadEnabled", ctx({ diagnosticsUploadDestination: "Hosted" }))).toBe(true); - expect(runtimeValue(RUNTIME_FIELDS, "diagnosticsUploadEnabled", ctx({ diagnosticsBlobSasUrl: "https://x.blob.core.windows.net/c?sig=1", diagnosticsUploadMode: "Off" }))).toBe(true); - }); - - it("unrestricted mode comes from the entitlement flags, never from the stored toggle alone", () => { - expect(runtimeValue(RUNTIME_FIELDS, "unrestrictedMode", ctx({ unrestrictedModeEnabled: true, unrestrictedMode: true }))).toBe(UNRESOLVED); - expect(runtimeValue(RUNTIME_FIELDS, "unrestrictedMode", ctx({ unrestrictedModeEnabled: true, unrestrictedMode: true }, { flags: flags(false) }))).toBe(false); - expect(runtimeValue(RUNTIME_FIELDS, "unrestrictedMode", ctx({}, { flags: flags(true) }))).toBe(true); - }); - - it("operator knobs resolve from the global config and are unresolved without it", () => { - expect(runtimeValue(COLLECTOR_FIELDS, "collectorIdleTimeoutMinutes", ctx())).toBe(UNRESOLVED); - const c = ctx({}, { adminConfig }); - expect(runtimeValue(COLLECTOR_FIELDS, "collectorIdleTimeoutMinutes", c)).toBe(25); - expect(runtimeValue(COLLECTOR_FIELDS, "desktopDetectorNoCandidateTimeoutMinutes", c)).toBe(7); - expect(runtimeValue(COLLECTOR_FIELDS, "modernDeploymentHarmlessEventIds", c)).toEqual([100, 42]); - expect(runtimeValue(RUNTIME_FIELDS, "allowAgentDowngrade", c)).toBe(true); - }); - - it("applies the analyzer defaults of the agent config builder", () => { - const c = ctx(); - expect(runtimeValue(ANALYZER_FIELDS, "enableLocalAdminAnalyzer", c)).toBe(true); - expect(runtimeValue(ANALYZER_FIELDS, "enableIntegrityBypassAnalyzer", c)).toBe(true); - expect(runtimeValue(ANALYZER_FIELDS, "enableConsoleBypassDetection", c)).toBe(true); - expect(runtimeValue(ANALYZER_FIELDS, "enableSoftwareInventoryAnalyzer", c)).toBe(false); - expect(runtimeValue(ANALYZER_FIELDS, "enableRealmJoinWatcher", c)).toBe(false); - expect(runtimeValue(ANALYZER_FIELDS, "keepAwakeDuringUserEsp", c)).toBe(false); - expect(runtimeValue(ANALYZER_FIELDS, "localAdminAllowedAccounts", ctx({ localAdminAllowedAccountsJson: '["Admin","Helpdesk"]' }))).toEqual(["Admin", "Helpdesk"]); - expect(runtimeValue(ANALYZER_FIELDS, "localAdminAllowedAccounts", ctx({ localAdminAllowedAccountsJson: "not json" }))).toEqual([]); - }); - - it("nullable agent knobs fall back to the agent defaults", () => { - const c = ctx(); - expect(runtimeValue(RUNTIME_FIELDS, "maxAuthFailures", c)).toBe(5); - expect(runtimeValue(RUNTIME_FIELDS, "authFailureTimeoutMinutes", c)).toBe(0); - expect(runtimeValue(RUNTIME_FIELDS, "logLevel", ctx({ logLevel: null as unknown as string }))).toBe("Info"); - expect(runtimeValue(RUNTIME_FIELDS, "ntpServer", ctx({ ntpServer: "" }))).toBe("time.windows.com"); - expect(runtimeValue(COLLECTOR_FIELDS, "agentMaxLifetimeMinutes", c)).toBe(360); - expect(runtimeValue(RUNTIME_FIELDS, "rebootDelaySeconds", ctx({ rebootDelaySeconds: 30 }))).toBe(30); +describe("runtime catalogs parity with the agent-config defaults manifest", () => { + it.each([ + ["responseDefaults", RUNTIME_FIELDS], + ["collectorDefaults", COLLECTOR_FIELDS], + ["analyzerDefaults", ANALYZER_FIELDS], + ] as const)("%s ↔ catalog: same key set", (section, record) => { + const keys = Object.keys(agentDefaults[section]); + expect(keys.filter((k) => !(k in record))).toEqual([]); + expect(Object.keys(record).filter((k) => !keys.includes(k))).toEqual([]); }); it("renders every runtime row in exactly one section", () => { const keys = RUNTIME_REPORT_SECTIONS.flatMap((s) => s.rows.map((r) => r.key)); expect(new Set(keys).size).toBe(keys.length); expect(keys).toContain("uploadIntervalSeconds"); - expect(keys).toContain("collectorIdleTimeoutMinutes"); - expect(keys).toContain("enableConsoleBypassDetection"); + expect(keys).toContain("collectors.collectorIdleTimeoutMinutes"); + expect(keys).toContain("analyzers.enableConsoleBypassDetection"); + expect(keys).toContain("configVersion"); + expect(keys).not.toContain("deviceKillSignal"); + }); + + it("collapsed sections exist in the section list", () => { + const sections = new Set(RUNTIME_REPORT_SECTIONS.map((s) => s.section)); + for (const s of RUNTIME_SECTIONS_COLLAPSED) expect(sections.has(s), s).toBe(true); + }); + + it("value accessors read the top level and the two sub-objects of the response", () => { + const response = { + uploadIntervalSeconds: 10, + collectors: { collectorIdleTimeoutMinutes: 25 }, + analyzers: { enableConsoleBypassDetection: false }, + diagnosticsLogPaths: [{ path: "a" }, { path: "b" }], + } as unknown as AgentConfigResponse; + const byKey = new Map(RUNTIME_REPORT_SECTIONS.flatMap((s) => s.rows.map((r) => [r.key, r] as const))); + + expect(byKey.get("uploadIntervalSeconds")!.value(response)).toBe(10); + expect(byKey.get("collectors.collectorIdleTimeoutMinutes")!.value(response)).toBe(25); + expect(byKey.get("analyzers.enableConsoleBypassDetection")!.value(response)).toBe(false); + expect(byKey.get("diagnosticsLogPaths")!.row.kind).toBe("count"); + expect(byKey.get("diagnosticsLogPaths")!.value(response)).toHaveLength(2); + }); + + it("pins the agent defaults the report highlights against", () => { + expect(agentDefaults.responseDefaults.uploadIntervalSeconds).toBe(10); + expect(agentDefaults.responseDefaults.maxBatchSize).toBe(100); + expect(agentDefaults.collectorDefaults.collectorIdleTimeoutMinutes).toBe(15); + expect(agentDefaults.analyzerDefaults.enableIntegrityBypassAnalyzer).toBe(true); + expect(agentDefaults.analyzerDefaults.enableRealmJoinWatcher).toBe(false); }); }); describe("isNonDefault", () => { - it("treats null and undefined as one value and compares arrays by content", () => { + it("treats null, undefined and the empty string as one value and compares arrays by content", () => { expect(isNonDefault(undefined, null)).toBe(false); expect(isNonDefault("", null)).toBe(false); expect(isNonDefault("", "Off")).toBe(true); diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts index 337c0bea..cddb2a6a 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts @@ -4,39 +4,56 @@ * Both halves of the report are exhaustive over the generated wire types: `TENANT_FIELDS` * over every key of `TenantConfiguration` (GET config/{tenantId}) and `RUNTIME_FIELDS` / * `COLLECTOR_FIELDS` / `ANALYZER_FIELDS` over every key of `AgentConfigResponse` and its two - * sub-objects (what GET agent/config serves). A key is either rendered (a row with section - * and label) or excluded with a reason — nothing can be silently forgotten: a new C# property - * forces the wire-type regeneration (SharedManifestParityTests) and `tsc` then fails here - * until the key is catalogued. The vitest next to this module pins the same thing at runtime - * against `SHARED_MANIFEST.tenantConfiguration.fields`. + * sub-objects (GET config/{tenantId}/effective-agent-config — the backend's own derivation, + * nothing is re-computed here). A key is either rendered (a row with section and label) or + * excluded with a reason — nothing can be silently forgotten: a new C# property forces the + * wire-type regeneration (SharedManifestParityTests) and `tsc` then fails here until the key + * is catalogued. * - * Runtime rows mirror the resolution rules of GetAgentConfigFunction.cs (backend); every - * rule carries the source it reads from so the operator sees which knob to turn. + * The "custom" highlight compares a value against the C# initializer of the same property, + * which the shared manifest carries as `defaults` (generated by the same test) — no + * hand-copied default anywhere in this file. */ +import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated"; import type { - AdminConfiguration, AgentConfigResponse, AnalyzerConfiguration, CollectorConfiguration, TenantConfiguration, - TenantFeatureFlagsResponse, } from "@/utils/wire-types.generated"; +// ── Defaults from the manifest (compile-time complete over the wire types) ──── + +export const TENANT_DEFAULTS: Record = SHARED_MANIFEST.tenantConfiguration.defaults; +export const RUNTIME_DEFAULTS: Record = SHARED_MANIFEST.agentConfig.responseDefaults; +export const COLLECTOR_DEFAULTS: Record = SHARED_MANIFEST.agentConfig.collectorDefaults; +export const ANALYZER_DEFAULTS: Record = SHARED_MANIFEST.agentConfig.analyzerDefaults; + // ── Row model ──────────────────────────────────────────────────────────────── -/** How a value is rendered. `secret` never shows the value, only whether one is set. */ -export type RowKind = "value" | "date" | "masked" | "secret"; +/** + * How a value is rendered. `secret` never shows the value, only whether one is set; + * `count` shows the length of a list. + */ +export type RowKind = "value" | "date" | "masked" | "secret" | "count"; -export interface TenantRow { - section: TenantSection; +interface RowBase { label: string; kind?: RowKind; - /** - * The C# initializer of TenantConfiguration.cs. A row WITH this key (even `null`) is - * compared against it and highlighted as "custom" when the stored value differs; a row - * without it is informational and never highlighted. - */ - default?: unknown; + /** Informational rows (reasons, timestamps, identities, secrets) are never marked "custom". */ + informational?: true; +} + +export interface TenantRow extends RowBase { + section: TenantSection; +} + +/** Which knob a runtime value is resolved from (AgentConfigResolver.Build). */ +export type RuntimeSource = "tenant" | "global" | "entitlement" | "fixed"; + +export interface RuntimeRow extends RowBase { + section: RuntimeSection; + source: RuntimeSource; } export interface Excluded { @@ -65,8 +82,6 @@ export const TENANT_SECTIONS = [ ] as const; export type TenantSection = (typeof TENANT_SECTIONS)[number]; -const NTP_DEFAULT = "time.windows.com"; - // ── Tenant configuration (left column) ─────────────────────────────────────── const HEADER = { excluded: "shown in the tenant header card" } as const; @@ -79,98 +94,97 @@ export const TENANT_FIELDS: Record = new Set(["Collector Internals"]); -export interface RuntimeRow { - section: RuntimeSection; - label: string; - source: RuntimeSource; - /** Backend resolution rule (GetAgentConfigFunction.cs). */ - value: (ctx: RuntimeContext) => unknown; - /** The agent-side default (AgentConfigResponse.cs / AdminConfiguration.cs initializer) for the "custom" highlight. */ - default?: unknown; -} - -/** Shared.Constants.DefaultUploadIntervalSeconds — the one runtime constant not on any config row. */ -export const DEFAULT_UPLOAD_INTERVAL_SECONDS = 10; - -/** GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled: a SAS URL or the hosted destination, regardless of mode. */ -export const resolveDiagnosticsUploadEnabled = (c: TenantConfiguration): boolean => - !!c.diagnosticsBlobSasUrl || (c.diagnosticsUploadDestination ?? "").toLowerCase() === "hosted"; - -/** TenantConfiguration.GetLocalAdminAllowedAccounts: JSON string array, anything else → empty. */ -export const parseLocalAdminAllowedAccounts = (json: string | null | undefined): string[] => { - if (!json) return []; - try { - const parsed: unknown = JSON.parse(json); - return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : []; - } catch { - return []; - } -}; - -/** AdminConfiguration.GetModernDeploymentHarmlessEventIds: JSON int array, blank/invalid → defaults. */ -export const parseModernDeploymentHarmlessEventIds = (json: string | null | undefined): number[] => { - const defaults = [100, 1005, 1010]; - if (!json || !json.trim()) return defaults; - try { - const parsed: unknown = JSON.parse(json); - return Array.isArray(parsed) && parsed.every((x) => typeof x === "number") ? parsed : defaults; - } catch { - return defaults; - } -}; - -const admin = (pick: (a: AdminConfiguration) => T) => - (ctx: RuntimeContext): T | typeof UNRESOLVED => (ctx.adminConfig ? pick(ctx.adminConfig) : UNRESOLVED); - -const NOT_CONFIG = { excluded: "per-request payload, not a configuration value" } as const; +const PER_DEVICE = { excluded: "per-device kill verdict, only on the agent channel" } as const; const SUB_OBJECT = { excluded: "sub-object, catalogued separately" } as const; -const CLASS_DEFAULT = { excluded: "agent class default, neither tenant- nor operator-tunable" } as const; + +/** Agent class default, neither tenant- nor operator-tunable — shown once, collapsed. */ +const internal = (label: string): RuntimeRow => ({ section: "Collector Internals", label, source: "fixed" }); export const RUNTIME_FIELDS: Record = { - configVersion: { excluded: "compiled into the backend" }, - uploadIntervalSeconds: { section: "Upload & Batching", label: "Upload Interval (sec)", source: "constant", value: () => DEFAULT_UPLOAD_INTERVAL_SECONDS, default: DEFAULT_UPLOAD_INTERVAL_SECONDS }, - maxBatchSize: { section: "Upload & Batching", label: "Max Batch Size", source: "tenant", value: ({ config }) => config.maxBatchSize ?? 100, default: 100 }, + configVersion: { section: "Catalogs & Integrity", label: "Config Version", source: "fixed", informational: true }, + uploadIntervalSeconds: { section: "Upload & Batching", label: "Upload Interval (sec)", source: "fixed" }, + maxBatchSize: { section: "Upload & Batching", label: "Max Batch Size", source: "tenant" }, - selfDestructOnComplete: { section: "Agent Behavior", label: "Self-Destruct", source: "tenant", value: ({ config }) => config.selfDestructOnComplete ?? true, default: true }, - keepLogFile: { section: "Agent Behavior", label: "Keep Log File", source: "tenant", value: ({ config }) => config.keepLogFile ?? false, default: false }, - rebootOnComplete: { section: "Agent Behavior", label: "Reboot On Complete", source: "tenant", value: ({ config }) => config.rebootOnComplete ?? false, default: false }, - rebootDelaySeconds: { section: "Agent Behavior", label: "Reboot Delay (sec)", source: "tenant", value: ({ config }) => config.rebootDelaySeconds ?? 10, default: 10 }, - enableGeoLocation: { section: "Agent Behavior", label: "Geo-Location", source: "tenant", value: ({ config }) => config.enableGeoLocation ?? true, default: true }, - enableTimezoneAutoSet: { section: "Agent Behavior", label: "Timezone Auto-Set", source: "tenant", value: ({ config }) => config.enableTimezoneAutoSet ?? false, default: false }, - enableDoGroupIdAutoSet: { section: "Agent Behavior", label: "DO GroupId Auto-Set", source: "tenant", value: ({ config }) => config.enableDoGroupIdAutoSet ?? false, default: false }, - ntpServer: { section: "Agent Behavior", label: "NTP Server", source: "tenant", value: ({ config }) => config.ntpServer || NTP_DEFAULT, default: NTP_DEFAULT }, - enableImeMatchLog: { section: "Agent Behavior", label: "IME Match Log", source: "tenant", value: ({ config }) => config.enableImeMatchLog ?? false, default: false }, - enableGatherRuleDebugLog: { section: "Agent Behavior", label: "Gather Rule Debug Log", source: "tenant", value: ({ config }) => config.enableGatherRuleDebugLog ?? false, default: false }, - enableEspContinueAnywayObservation: { section: "Agent Behavior", label: "Continue-Anyway Observation", source: "tenant", value: ({ config }) => config.enableEspContinueAnywayObservation ?? false, default: false }, - logLevel: { section: "Agent Behavior", label: "Log Level", source: "tenant", value: ({ config }) => config.logLevel ?? "Info", default: "Info" }, - sendTraceEvents: { section: "Agent Behavior", label: "Send Trace Events", source: "tenant", value: ({ config }) => config.sendTraceEvents, default: true }, - // TenantEntitlementService.IsUnrestrictedModeActive = edition entitlement && GA gate && tenant toggle; - // feature-flags carries the backend-resolved value, so the entitlement logic is not rebuilt here. - unrestrictedMode: { section: "Agent Behavior", label: "Unrestricted Mode (effective)", source: "flags", value: ({ flags }) => (flags ? flags.unrestrictedMode : UNRESOLVED), default: false }, - allowAgentDowngrade: { section: "Agent Behavior", label: "Allow Agent Downgrade", source: "admin", value: admin((a) => a.allowAgentDowngrade), default: false }, + selfDestructOnComplete: { section: "Agent Behavior", label: "Self-Destruct", source: "tenant" }, + keepLogFile: { section: "Agent Behavior", label: "Keep Log File", source: "tenant" }, + rebootOnComplete: { section: "Agent Behavior", label: "Reboot On Complete", source: "tenant" }, + rebootDelaySeconds: { section: "Agent Behavior", label: "Reboot Delay (sec)", source: "tenant" }, + enableGeoLocation: { section: "Agent Behavior", label: "Geo-Location", source: "tenant" }, + enableTimezoneAutoSet: { section: "Agent Behavior", label: "Timezone Auto-Set", source: "tenant" }, + enableDoGroupIdAutoSet: { section: "Agent Behavior", label: "DO GroupId Auto-Set", source: "tenant" }, + ntpServer: { section: "Agent Behavior", label: "NTP Server", source: "tenant" }, + enableImeMatchLog: { section: "Agent Behavior", label: "IME Match Log", source: "tenant" }, + enableGatherRuleDebugLog: { section: "Agent Behavior", label: "Gather Rule Debug Log", source: "tenant" }, + enableEspContinueAnywayObservation: { section: "Agent Behavior", label: "Continue-Anyway Observation", source: "tenant" }, + logLevel: { section: "Agent Behavior", label: "Log Level", source: "tenant" }, + sendTraceEvents: { section: "Agent Behavior", label: "Send Trace Events", source: "tenant" }, + // TenantEntitlementService.IsUnrestrictedModeActive = edition entitlement && GA gate && tenant toggle. + unrestrictedMode: { section: "Agent Behavior", label: "Unrestricted Mode (effective)", source: "entitlement" }, + allowAgentDowngrade: { section: "Agent Behavior", label: "Allow Agent Downgrade", source: "global" }, - maxAuthFailures: { section: "Auth Circuit Breaker", label: "Max Auth Failures", source: "tenant", value: ({ config }) => config.maxAuthFailures ?? 5, default: 5 }, - authFailureTimeoutMinutes: { section: "Auth Circuit Breaker", label: "Auth Failure Timeout (min)", source: "tenant", value: ({ config }) => config.authFailureTimeoutMinutes ?? 0, default: 0 }, + maxAuthFailures: { section: "Auth Circuit Breaker", label: "Max Auth Failures", source: "tenant" }, + authFailureTimeoutMinutes: { section: "Auth Circuit Breaker", label: "Auth Failure Timeout (min)", source: "tenant" }, - showEnrollmentSummary: { section: "Enrollment Summary", label: "Show Summary", source: "tenant", value: ({ config }) => config.showEnrollmentSummary ?? false, default: false }, - enrollmentSummaryTimeoutSeconds: { section: "Enrollment Summary", label: "Timeout (sec)", source: "tenant", value: ({ config }) => config.enrollmentSummaryTimeoutSeconds ?? 60, default: 60 }, - enrollmentSummaryBrandingImageUrl: { section: "Enrollment Summary", label: "Branding Image URL", source: "tenant", value: ({ config }) => config.enrollmentSummaryBrandingImageUrl ?? null }, - enrollmentSummaryLaunchRetrySeconds: { section: "Enrollment Summary", label: "Launch Retry (sec)", source: "tenant", value: ({ config }) => config.enrollmentSummaryLaunchRetrySeconds ?? 120, default: 120 }, + showEnrollmentSummary: { section: "Enrollment Summary", label: "Show Summary", source: "tenant" }, + enrollmentSummaryTimeoutSeconds: { section: "Enrollment Summary", label: "Timeout (sec)", source: "tenant" }, + enrollmentSummaryBrandingImageUrl: { section: "Enrollment Summary", label: "Branding Image URL", source: "tenant" }, + enrollmentSummaryLaunchRetrySeconds: { section: "Enrollment Summary", label: "Launch Retry (sec)", source: "tenant" }, - diagnosticsUploadEnabled: { section: "Diagnostics", label: "Upload Enabled", source: "tenant", value: ({ config }) => resolveDiagnosticsUploadEnabled(config), default: false }, - diagnosticsUploadMode: { section: "Diagnostics", label: "Upload Mode", source: "tenant", value: ({ config }) => config.diagnosticsUploadMode ?? "Off", default: "Off" }, - diagnosticsLogPaths: { excluded: "merged global + tenant path list (see Custom Log Paths and the global diagnostics settings)" }, + diagnosticsUploadEnabled: { section: "Diagnostics", label: "Upload Enabled", source: "tenant" }, + diagnosticsUploadMode: { section: "Diagnostics", label: "Upload Mode", source: "tenant" }, + diagnosticsLogPaths: { section: "Diagnostics", label: "Log Paths (global + tenant)", source: "global", kind: "count" }, collectors: SUB_OBJECT, analyzers: SUB_OBJECT, - gatherRules: { excluded: "active gather-rule catalog for the tenant" }, - imeLogPatterns: { excluded: "active IME log-pattern catalog for the tenant" }, - whiteGloveSealingPatternIds: { excluded: "global pattern-id list (AdminConfiguration)" }, - latestAgentSha256: { excluded: "per-major agent hash oracle selected from the caller's X-Agent-Version (AdminConfiguration)" }, - latestAgentExeSha256: { excluded: "per-major agent hash oracle selected from the caller's X-Agent-Version (AdminConfiguration)" }, - deviceBlocked: NOT_CONFIG, - deviceKillSignal: NOT_CONFIG, - unblockAt: NOT_CONFIG, - migrateToApiBaseUrl: { excluded: "endpoint-migration target, global or per-tenant override (AdminConfiguration)" }, + + gatherRules: { section: "Catalogs & Integrity", label: "Active Gather Rules", source: "tenant", kind: "count", informational: true }, + imeLogPatterns: { section: "Catalogs & Integrity", label: "Active IME Log Patterns", source: "tenant", kind: "count", informational: true }, + whiteGloveSealingPatternIds: { section: "Catalogs & Integrity", label: "White Glove Sealing Pattern IDs", source: "global", informational: true }, + latestAgentSha256: { section: "Catalogs & Integrity", label: "Latest Agent ZIP SHA-256", source: "global", informational: true }, + latestAgentExeSha256: { section: "Catalogs & Integrity", label: "Latest Agent EXE SHA-256", source: "global", informational: true }, + migrateToApiBaseUrl: { section: "Catalogs & Integrity", label: "Migrate To API Base URL", source: "global" }, + + deviceBlocked: PER_DEVICE, + deviceKillSignal: PER_DEVICE, + unblockAt: PER_DEVICE, }; export const COLLECTOR_FIELDS: Record = { - enablePerformanceCollector: { section: "Collectors", label: "Performance Collector", source: "tenant", value: ({ config }) => config.enablePerformanceCollector, default: true }, - performanceIntervalSeconds: { section: "Collectors", label: "Perf. Interval (sec)", source: "tenant", value: ({ config }) => config.performanceCollectorIntervalSeconds, default: 30 }, - collectorIdleTimeoutMinutes: { section: "Collectors", label: "Idle Timeout (min)", source: "admin", value: admin((a) => a.collectorIdleTimeoutMinutes), default: 15 }, - desktopDetectorNoCandidateTimeoutMinutes: { section: "Collectors", label: "Desktop Detector No-Candidate Timeout (min)", source: "admin", value: admin((a) => a.desktopDetectorNoCandidateTimeoutMinutes), default: 10 }, - enableAgentSelfMetrics: { section: "Collectors", label: "Agent Self-Metrics", source: "constant", value: () => true, default: true }, - agentSelfMetricsIntervalSeconds: { section: "Collectors", label: "Self-Metrics Interval (sec)", source: "constant", value: () => 60, default: 60 }, - helloWaitTimeoutSeconds: { section: "Collectors", label: "Hello Wait Timeout (sec)", source: "tenant", value: ({ config }) => config.helloWaitTimeoutSeconds, default: 30 }, - agentMaxLifetimeMinutes: { section: "Collectors", label: "Agent Max Lifetime (min)", source: "tenant", value: ({ config }) => config.agentMaxLifetimeMinutes ?? 360, default: 360 }, - modernDeploymentHarmlessEventIds: { section: "Collectors", label: "Modern Deployment Harmless Event IDs", source: "admin", value: admin((a) => parseModernDeploymentHarmlessEventIds(a.modernDeploymentHarmlessEventIdsJson)), default: [100, 1005, 1010] }, - enableDeliveryOptimizationCollector: CLASS_DEFAULT, - deliveryOptimizationIntervalSeconds: CLASS_DEFAULT, - enableOfficeInstallDetector: CLASS_DEFAULT, - officeInstallSettleSeconds: CLASS_DEFAULT, - enablePowerStateWatcher: CLASS_DEFAULT, - stallProbeEnabled: CLASS_DEFAULT, - stallProbeThresholdsMinutes: CLASS_DEFAULT, - stallProbeTraceIndices: CLASS_DEFAULT, - stallProbeSources: CLASS_DEFAULT, - sessionStalledAfterProbeIndex: CLASS_DEFAULT, - modernDeploymentWatcherEnabled: CLASS_DEFAULT, - modernDeploymentLogLevelMax: CLASS_DEFAULT, - modernDeploymentBackfillEnabled: CLASS_DEFAULT, - modernDeploymentBackfillLookbackMinutes: CLASS_DEFAULT, - windowsUpdateWatcherEnabled: CLASS_DEFAULT, - windowsUpdateTargetedEventIds: CLASS_DEFAULT, - windowsUpdateBackfillLookbackMinutes: CLASS_DEFAULT, - windowsUpdateChannelCensusEnabled: CLASS_DEFAULT, - mdmRebootPolicyWatcherEnabled: CLASS_DEFAULT, - mdmRebootPolicyBackfillLookbackMinutes: CLASS_DEFAULT, - enableSystemTimelineWatcher: CLASS_DEFAULT, - systemEventBackfillLookbackMinutes: CLASS_DEFAULT, + enablePerformanceCollector: { section: "Collectors", label: "Performance Collector", source: "tenant" }, + performanceIntervalSeconds: { section: "Collectors", label: "Perf. Interval (sec)", source: "tenant" }, + collectorIdleTimeoutMinutes: { section: "Collectors", label: "Idle Timeout (min)", source: "global" }, + desktopDetectorNoCandidateTimeoutMinutes: { section: "Collectors", label: "Desktop Detector No-Candidate Timeout (min)", source: "global" }, + enableAgentSelfMetrics: { section: "Collectors", label: "Agent Self-Metrics", source: "fixed" }, + agentSelfMetricsIntervalSeconds: { section: "Collectors", label: "Self-Metrics Interval (sec)", source: "fixed" }, + helloWaitTimeoutSeconds: { section: "Collectors", label: "Hello Wait Timeout (sec)", source: "tenant" }, + agentMaxLifetimeMinutes: { section: "Collectors", label: "Agent Max Lifetime (min)", source: "tenant" }, + modernDeploymentHarmlessEventIds: { section: "Collectors", label: "Modern Deployment Harmless Event IDs", source: "global" }, + enableDeliveryOptimizationCollector: internal("Delivery Optimization Collector"), + deliveryOptimizationIntervalSeconds: internal("DO Interval (sec)"), + enableOfficeInstallDetector: internal("Office Install Detector"), + officeInstallSettleSeconds: internal("Office Install Settle (sec)"), + enablePowerStateWatcher: internal("Power State Watcher"), + stallProbeEnabled: internal("Stall Probe"), + stallProbeThresholdsMinutes: internal("Stall Probe Thresholds (min)"), + stallProbeTraceIndices: internal("Stall Probe Trace Indices"), + stallProbeSources: internal("Stall Probe Sources"), + sessionStalledAfterProbeIndex: internal("Session Stalled After Probe Index"), + modernDeploymentWatcherEnabled: internal("Modern Deployment Watcher"), + modernDeploymentLogLevelMax: internal("Modern Deployment Log Level Max"), + modernDeploymentBackfillEnabled: internal("Modern Deployment Backfill"), + modernDeploymentBackfillLookbackMinutes: internal("Modern Deployment Backfill Lookback (min)"), + windowsUpdateWatcherEnabled: internal("Windows Update Watcher"), + windowsUpdateTargetedEventIds: internal("Windows Update Targeted Event IDs"), + windowsUpdateBackfillLookbackMinutes: internal("Windows Update Backfill Lookback (min)"), + windowsUpdateChannelCensusEnabled: internal("Windows Update Channel Census"), + mdmRebootPolicyWatcherEnabled: internal("MDM Reboot Policy Watcher"), + mdmRebootPolicyBackfillLookbackMinutes: internal("MDM Reboot Policy Backfill Lookback (min)"), + enableSystemTimelineWatcher: internal("System Timeline Watcher"), + systemEventBackfillLookbackMinutes: internal("System Event Backfill Lookback (min)"), }; export const ANALYZER_FIELDS: Record = { - enableLocalAdminAnalyzer: { section: "Analyzers", label: "Local Admin Analyzer", source: "tenant", value: ({ config }) => config.enableLocalAdminAnalyzer ?? true, default: true }, - localAdminAllowedAccounts: { section: "Analyzers", label: "Allowed Local Admin Accounts", source: "tenant", value: ({ config }) => parseLocalAdminAllowedAccounts(config.localAdminAllowedAccountsJson), default: [] }, - enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer", source: "tenant", value: ({ config }) => config.enableSoftwareInventoryAnalyzer ?? false, default: false }, - enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer", source: "tenant", value: ({ config }) => config.enableIntegrityBypassAnalyzer ?? true, default: true }, - enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher", source: "tenant", value: ({ config }) => config.enableRealmJoinWatcher ?? false, default: false }, - keepAwakeDuringUserEsp: { section: "Analyzers", label: "Keep Awake During User ESP", source: "tenant", value: ({ config }) => config.keepAwakeDuringUserEsp ?? false, default: false }, - enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection", source: "tenant", value: ({ config }) => config.enableConsoleBypassDetection ?? true, default: true }, + enableLocalAdminAnalyzer: { section: "Analyzers", label: "Local Admin Analyzer", source: "tenant" }, + localAdminAllowedAccounts: { section: "Analyzers", label: "Allowed Local Admin Accounts", source: "tenant" }, + enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer", source: "tenant" }, + enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer", source: "tenant" }, + enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher", source: "tenant" }, + keepAwakeDuringUserEsp: { section: "Analyzers", label: "Keep Awake During User ESP", source: "tenant" }, + enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection", source: "tenant" }, }; // ── Ordered views for rendering ────────────────────────────────────────────── -export interface CataloguedRow { +/** One rendered row: where its value and default come from, resolved against a response. */ +export interface CataloguedRuntimeRow { key: string; - row: TRow; + row: RuntimeRow; + value: (r: AgentConfigResponse) => unknown; + default: unknown; } -function groupBySection( - sections: readonly TSection[], - records: Array>, -): Array<{ section: TSection; rows: CataloguedRow[] }> { - const bySection = new Map[]>(sections.map((s) => [s, []])); - for (const record of records) { - for (const [key, spec] of Object.entries(record)) { - if (isExcluded(spec)) continue; - bySection.get(spec.section)!.push({ key, row: spec }); - } +export interface CataloguedTenantRow { + key: keyof TenantConfiguration; + row: TenantRow; + default: unknown; +} + +function tenantRows(): Array<{ section: TenantSection; rows: CataloguedTenantRow[] }> { + const bySection = new Map(TENANT_SECTIONS.map((s) => [s, []])); + for (const [key, spec] of Object.entries(TENANT_FIELDS) as Array<[keyof TenantConfiguration, TenantRow | Excluded]>) { + if (isExcluded(spec)) continue; + bySection.get(spec.section)!.push({ key, row: spec, default: TENANT_DEFAULTS[key] }); } - return sections.map((section) => ({ section, rows: bySection.get(section)! })); + return TENANT_SECTIONS.map((section) => ({ section, rows: bySection.get(section)! })); } -export const TENANT_REPORT_SECTIONS = groupBySection(TENANT_SECTIONS, [TENANT_FIELDS]); +function runtimeRows(): Array<{ section: RuntimeSection; rows: CataloguedRuntimeRow[] }> { + const bySection = new Map(RUNTIME_SECTIONS.map((s) => [s, []])); + const add = (key: string, spec: RuntimeRow | Excluded, value: (r: AgentConfigResponse) => unknown, def: unknown) => { + if (isExcluded(spec)) return; + bySection.get(spec.section)!.push({ key, row: spec, value, default: def }); + }; + for (const [key, spec] of Object.entries(RUNTIME_FIELDS) as Array<[keyof AgentConfigResponse, RuntimeRow | Excluded]>) { + add(key, spec, (r) => r[key], RUNTIME_DEFAULTS[key]); + } + for (const [key, spec] of Object.entries(COLLECTOR_FIELDS) as Array<[keyof CollectorConfiguration, RuntimeRow | Excluded]>) { + add(`collectors.${key}`, spec, (r) => r.collectors?.[key], COLLECTOR_DEFAULTS[key]); + } + for (const [key, spec] of Object.entries(ANALYZER_FIELDS) as Array<[keyof AnalyzerConfiguration, RuntimeRow | Excluded]>) { + add(`analyzers.${key}`, spec, (r) => r.analyzers?.[key], ANALYZER_DEFAULTS[key]); + } + return RUNTIME_SECTIONS.map((section) => ({ section, rows: bySection.get(section)! })); +} -export const RUNTIME_REPORT_SECTIONS = groupBySection(RUNTIME_SECTIONS, [ - RUNTIME_FIELDS, - COLLECTOR_FIELDS, - ANALYZER_FIELDS, -]); +export const TENANT_REPORT_SECTIONS = tenantRows(); +export const RUNTIME_REPORT_SECTIONS = runtimeRows(); /** * Value equality for the "custom" highlight: null, undefined and the empty string are one diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 006bf59b..39e8c26f 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -135,6 +135,13 @@ export const api = { */ fields: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/fields`, featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/feature-flags`, + /** + * GET — GA-only: the AgentConfigResponse an agent of the tenant receives right now (same + * derivation as agent/config, minus the per-device kill verdict). Optional agentVersion + * selects the hash-oracle line like X-Agent-Version would; absent = the current line. + */ + effectiveAgentConfig: (tenantId: string, agentVersion?: string) => + `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/effective-agent-config${agentVersion ? `?agentVersion=${encodeURIComponent(agentVersion)}` : ''}`, /** PATCH — GA-only plan/trial mutation: { planTier?, trialExpiresUtc?: ISO | null }. */ plan: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/plan`, /** POST — tenant-admin self-service 30-day Pro trial (once per tenant, 409 after). */ diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index 5e103834..f1f533a0 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -60,7 +60,62 @@ export const SHARED_MANIFEST = { "msrcLastSyncUtc", "nvdCacheLastRefreshUtc", "epssLastSyncUtc" - ] + ], + "defaults": { + "partitionKey": "GlobalConfig", + "rowKey": "config", + "lastUpdated": "0001-01-01T00:00:00.0000000", + "updatedBy": null, + "globalRateLimitRequestsPerMinute": 100, + "userRateLimitRequestsPerMinute": 120, + "portalUserRateLimitRequestsPerMinute": 600, + "globalAdminRateLimitRequestsPerMinute": 600, + "planTierDefinitionsJson": null, + "platformStatsBlobSasUrl": "", + "collectorIdleTimeoutMinutes": 15, + "desktopDetectorNoCandidateTimeoutMinutes": 10, + "excessiveEventCountThreshold": 2000, + "excessiveEventAutoActionMode": "Off", + "excessiveEventAutoActionThreshold": 2500, + "excessiveEventAutoActionDurationHours": 24, + "opsEventRetentionDays": 90, + "slaNotificationCooldownHours": 24, + "feedbackEnabled": true, + "feedbackMinTenantAgeDays": 14, + "feedbackCooldownDays": 60, + "diagnosticsGlobalLogPathsJson": null, + "maxDiagnosticsDownloadSizeMB": 500, + "diagnosticsDownloadTimeoutSeconds": 120, + "mcpAccessPolicy": "WhitelistOnly", + "nvdApiKey": null, + "opsAlertRulesJson": null, + "opsAlertTelegramEnabled": false, + "opsAlertTelegramChatId": null, + "opsAlertTeamsEnabled": false, + "opsAlertTeamsWebhookUrl": null, + "opsAlertSlackEnabled": false, + "opsAlertSlackWebhookUrl": null, + "opsNotificationChannelsJson": null, + "latestAgentV2Version": null, + "latestAgentV2Sha256": null, + "latestAgentV2ExeSha256": null, + "latestBootstrapV2ScriptVersion": null, + "allowAgentDowngrade": false, + "agentMigrateApiBaseUrl": "", + "agentMigrateTenantOverridesJson": "", + "modernDeploymentHarmlessEventIdsJson": null, + "whiteGloveSealingPatternIdsJson": null, + "vulnerabilityCorrelationEnabled": true, + "autoApproveNewTenants": false, + "imeMsiArchivingEnabled": true, + "maxImeMsiDownloadSizeMB": 250, + "selfServiceAppHomingEnabled": false, + "sessionDeletionKillSwitch": false, + "vulnerabilityDataLastSyncUtc": null, + "msrcLastSyncUtc": null, + "nvdCacheLastRefreshUtc": null, + "epssLastSyncUtc": null + } }, "tenantConfiguration": { "fields": [ @@ -166,7 +221,217 @@ export const SHARED_MANIFEST = { "slaNotifyOnAppInstallBreach", "slaNotifyOnConsecutiveFailures", "slaConsecutiveFailureThreshold" - ] + ], + "defaults": { + "tenantId": null, + "domainName": null, + "lastUpdated": "0001-01-01T00:00:00.0000000", + "updatedBy": null, + "onboardedBy": null, + "contactEmail": null, + "companyName": null, + "onboardedAt": null, + "homedAppClientId": null, + "lastAuthClientId": null, + "lastAuthClientIdSince": null, + "disabled": false, + "disabledReason": null, + "disabledUntil": null, + "mcpDisabled": false, + "mcpDisabledReason": null, + "customRateLimitRequestsPerMinute": null, + "customUserRateLimitRequestsPerMinute": null, + "planTier": "free", + "trialExpiresUtc": null, + "trialStartedUtc": null, + "trialConsumed": false, + "trialGrantedBy": null, + "proDowngradedUtc": null, + "maxDelegatedTenantsOverride": null, + "mcpUsagePlanOverride": null, + "payingCustomer": false, + "managedByProTenantId": null, + "manufacturerWhitelist": "Dell*,HP*,Lenovo*,Microsoft Corporation", + "modelWhitelist": "*", + "validateAutopilotDevice": false, + "validateCorporateIdentifier": false, + "validateDeviceAssociation": false, + "validateCloudPcDevice": false, + "validateIntuneDeviceBinding": false, + "allowInsecureAgentRequests": false, + "dataRetentionDays": 90, + "sessionTimeoutHours": 5, + "sessionGraceHours": 0, + "maxNdjsonPayloadSizeMB": 5, + "enablePerformanceCollector": true, + "performanceCollectorIntervalSeconds": 30, + "helloWaitTimeoutSeconds": 30, + "maxAuthFailures": null, + "authFailureTimeoutMinutes": null, + "agentMaxLifetimeMinutes": null, + "absoluteMaxSessionHours": null, + "selfDestructOnComplete": true, + "keepLogFile": false, + "rebootOnComplete": null, + "rebootDelaySeconds": null, + "enableGeoLocation": null, + "ntpServer": "time.windows.com", + "enableTimezoneAutoSet": null, + "enableDoGroupIdAutoSet": null, + "enableImeMatchLog": null, + "enableGatherRuleDebugLog": null, + "enableEspContinueAnywayObservation": null, + "logLevel": null, + "maxBatchSize": null, + "showEnrollmentSummary": null, + "enrollmentSummaryTimeoutSeconds": null, + "enrollmentSummaryBrandingImageUrl": null, + "enrollmentSummaryLaunchRetrySeconds": null, + "showScriptOutput": true, + "enableLocalAdminAnalyzer": null, + "enableSoftwareInventoryAnalyzer": null, + "enableIntegrityBypassAnalyzer": null, + "enableRealmJoinWatcher": null, + "keepAwakeDuringUserEsp": null, + "enableConsoleBypassDetection": null, + "localAdminAllowedAccountsJson": null, + "bootstrapTokenEnabled": false, + "unrestrictedModeEnabled": false, + "unrestrictedMode": false, + "entraAppRolesEnabled": false, + "diagnosticsLogPathsJson": null, + "diagnosticsBlobSasUrl": null, + "diagnosticsUploadMode": "Off", + "diagnosticsUploadDestination": "CustomerSas", + "sendTraceEvents": true, + "teamsWebhookUrl": null, + "teamsNotifyOnSuccess": true, + "teamsNotifyOnFailure": true, + "teamsNotifyOnStart": false, + "webhookProviderType": 0, + "webhookUrl": null, + "webhookNotifyOnSuccess": true, + "webhookNotifyOnFailure": true, + "webhookNotifyOnHardwareRejection": false, + "webhookNotifyOnStart": false, + "webhookCustomHeadersJson": null, + "notificationChannelsJson": null, + "slaTargetSuccessRate": null, + "slaTargetMaxDurationMinutes": null, + "slaTargetAppInstallSuccessRate": null, + "slaNotifyOnSuccessRateBreach": false, + "slaSuccessRateNotifyThreshold": null, + "slaNotifyOnDurationBreach": false, + "slaNotifyOnAppInstallBreach": false, + "slaNotifyOnConsecutiveFailures": false, + "slaConsecutiveFailureThreshold": 5 + } + }, + "agentConfig": { + "responseDefaults": { + "configVersion": 0, + "uploadIntervalSeconds": 10, + "selfDestructOnComplete": true, + "keepLogFile": false, + "enableGeoLocation": true, + "enableImeMatchLog": false, + "enableGatherRuleDebugLog": false, + "enableEspContinueAnywayObservation": false, + "collectors": null, + "gatherRules": [], + "imeLogPatterns": [], + "whiteGloveSealingPatternIds": [], + "maxAuthFailures": 5, + "authFailureTimeoutMinutes": 0, + "logLevel": "Info", + "rebootOnComplete": false, + "rebootDelaySeconds": 10, + "showEnrollmentSummary": false, + "enrollmentSummaryTimeoutSeconds": 60, + "enrollmentSummaryBrandingImageUrl": null, + "enrollmentSummaryLaunchRetrySeconds": 120, + "maxBatchSize": 100, + "diagnosticsUploadEnabled": false, + "diagnosticsUploadMode": "Off", + "diagnosticsLogPaths": [], + "analyzers": null, + "sendTraceEvents": true, + "unrestrictedMode": false, + "latestAgentSha256": null, + "latestAgentExeSha256": null, + "allowAgentDowngrade": false, + "ntpServer": "time.windows.com", + "enableTimezoneAutoSet": false, + "enableDoGroupIdAutoSet": false, + "deviceBlocked": false, + "deviceKillSignal": false, + "unblockAt": null, + "migrateToApiBaseUrl": null + }, + "collectorDefaults": { + "enablePerformanceCollector": true, + "performanceIntervalSeconds": 30, + "collectorIdleTimeoutMinutes": 15, + "desktopDetectorNoCandidateTimeoutMinutes": 10, + "enableAgentSelfMetrics": true, + "agentSelfMetricsIntervalSeconds": 60, + "helloWaitTimeoutSeconds": 30, + "agentMaxLifetimeMinutes": 360, + "enableDeliveryOptimizationCollector": true, + "deliveryOptimizationIntervalSeconds": 3, + "enableOfficeInstallDetector": true, + "officeInstallSettleSeconds": 10, + "enablePowerStateWatcher": true, + "stallProbeEnabled": true, + "stallProbeThresholdsMinutes": [ + 2, + 15, + 30, + 60, + 180 + ], + "stallProbeTraceIndices": [ + 2 + ], + "stallProbeSources": [ + "provisioning_registry", + "diagnostics_registry", + "eventlog", + "appworkload_log" + ], + "sessionStalledAfterProbeIndex": 4, + "modernDeploymentWatcherEnabled": true, + "modernDeploymentLogLevelMax": 3, + "modernDeploymentBackfillEnabled": true, + "modernDeploymentHarmlessEventIds": [ + 100, + 1005, + 1010 + ], + "modernDeploymentBackfillLookbackMinutes": 30, + "windowsUpdateWatcherEnabled": true, + "windowsUpdateTargetedEventIds": [ + 19, + 20, + 43, + 44 + ], + "windowsUpdateBackfillLookbackMinutes": 60, + "windowsUpdateChannelCensusEnabled": true, + "mdmRebootPolicyWatcherEnabled": true, + "mdmRebootPolicyBackfillLookbackMinutes": 60, + "enableSystemTimelineWatcher": true, + "systemEventBackfillLookbackMinutes": 1440 + }, + "analyzerDefaults": { + "enableLocalAdminAnalyzer": true, + "localAdminAllowedAccounts": [], + "enableSoftwareInventoryAnalyzer": false, + "enableIntegrityBypassAnalyzer": true, + "enableRealmJoinWatcher": false, + "keepAwakeDuringUserEsp": false, + "enableConsoleBypassDetection": true + } }, "sessionStatuses": [ "InProgress", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 8bc46f32..9031ab56 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -56,7 +56,62 @@ "msrcLastSyncUtc", "nvdCacheLastRefreshUtc", "epssLastSyncUtc" - ] + ], + "defaults": { + "partitionKey": "GlobalConfig", + "rowKey": "config", + "lastUpdated": "0001-01-01T00:00:00.0000000", + "updatedBy": null, + "globalRateLimitRequestsPerMinute": 100, + "userRateLimitRequestsPerMinute": 120, + "portalUserRateLimitRequestsPerMinute": 600, + "globalAdminRateLimitRequestsPerMinute": 600, + "planTierDefinitionsJson": null, + "platformStatsBlobSasUrl": "", + "collectorIdleTimeoutMinutes": 15, + "desktopDetectorNoCandidateTimeoutMinutes": 10, + "excessiveEventCountThreshold": 2000, + "excessiveEventAutoActionMode": "Off", + "excessiveEventAutoActionThreshold": 2500, + "excessiveEventAutoActionDurationHours": 24, + "opsEventRetentionDays": 90, + "slaNotificationCooldownHours": 24, + "feedbackEnabled": true, + "feedbackMinTenantAgeDays": 14, + "feedbackCooldownDays": 60, + "diagnosticsGlobalLogPathsJson": null, + "maxDiagnosticsDownloadSizeMB": 500, + "diagnosticsDownloadTimeoutSeconds": 120, + "mcpAccessPolicy": "WhitelistOnly", + "nvdApiKey": null, + "opsAlertRulesJson": null, + "opsAlertTelegramEnabled": false, + "opsAlertTelegramChatId": null, + "opsAlertTeamsEnabled": false, + "opsAlertTeamsWebhookUrl": null, + "opsAlertSlackEnabled": false, + "opsAlertSlackWebhookUrl": null, + "opsNotificationChannelsJson": null, + "latestAgentV2Version": null, + "latestAgentV2Sha256": null, + "latestAgentV2ExeSha256": null, + "latestBootstrapV2ScriptVersion": null, + "allowAgentDowngrade": false, + "agentMigrateApiBaseUrl": "", + "agentMigrateTenantOverridesJson": "", + "modernDeploymentHarmlessEventIdsJson": null, + "whiteGloveSealingPatternIdsJson": null, + "vulnerabilityCorrelationEnabled": true, + "autoApproveNewTenants": false, + "imeMsiArchivingEnabled": true, + "maxImeMsiDownloadSizeMB": 250, + "selfServiceAppHomingEnabled": false, + "sessionDeletionKillSwitch": false, + "vulnerabilityDataLastSyncUtc": null, + "msrcLastSyncUtc": null, + "nvdCacheLastRefreshUtc": null, + "epssLastSyncUtc": null + } }, "tenantConfiguration": { "fields": [ @@ -162,7 +217,217 @@ "slaNotifyOnAppInstallBreach", "slaNotifyOnConsecutiveFailures", "slaConsecutiveFailureThreshold" - ] + ], + "defaults": { + "tenantId": null, + "domainName": null, + "lastUpdated": "0001-01-01T00:00:00.0000000", + "updatedBy": null, + "onboardedBy": null, + "contactEmail": null, + "companyName": null, + "onboardedAt": null, + "homedAppClientId": null, + "lastAuthClientId": null, + "lastAuthClientIdSince": null, + "disabled": false, + "disabledReason": null, + "disabledUntil": null, + "mcpDisabled": false, + "mcpDisabledReason": null, + "customRateLimitRequestsPerMinute": null, + "customUserRateLimitRequestsPerMinute": null, + "planTier": "free", + "trialExpiresUtc": null, + "trialStartedUtc": null, + "trialConsumed": false, + "trialGrantedBy": null, + "proDowngradedUtc": null, + "maxDelegatedTenantsOverride": null, + "mcpUsagePlanOverride": null, + "payingCustomer": false, + "managedByProTenantId": null, + "manufacturerWhitelist": "Dell*,HP*,Lenovo*,Microsoft Corporation", + "modelWhitelist": "*", + "validateAutopilotDevice": false, + "validateCorporateIdentifier": false, + "validateDeviceAssociation": false, + "validateCloudPcDevice": false, + "validateIntuneDeviceBinding": false, + "allowInsecureAgentRequests": false, + "dataRetentionDays": 90, + "sessionTimeoutHours": 5, + "sessionGraceHours": 0, + "maxNdjsonPayloadSizeMB": 5, + "enablePerformanceCollector": true, + "performanceCollectorIntervalSeconds": 30, + "helloWaitTimeoutSeconds": 30, + "maxAuthFailures": null, + "authFailureTimeoutMinutes": null, + "agentMaxLifetimeMinutes": null, + "absoluteMaxSessionHours": null, + "selfDestructOnComplete": true, + "keepLogFile": false, + "rebootOnComplete": null, + "rebootDelaySeconds": null, + "enableGeoLocation": null, + "ntpServer": "time.windows.com", + "enableTimezoneAutoSet": null, + "enableDoGroupIdAutoSet": null, + "enableImeMatchLog": null, + "enableGatherRuleDebugLog": null, + "enableEspContinueAnywayObservation": null, + "logLevel": null, + "maxBatchSize": null, + "showEnrollmentSummary": null, + "enrollmentSummaryTimeoutSeconds": null, + "enrollmentSummaryBrandingImageUrl": null, + "enrollmentSummaryLaunchRetrySeconds": null, + "showScriptOutput": true, + "enableLocalAdminAnalyzer": null, + "enableSoftwareInventoryAnalyzer": null, + "enableIntegrityBypassAnalyzer": null, + "enableRealmJoinWatcher": null, + "keepAwakeDuringUserEsp": null, + "enableConsoleBypassDetection": null, + "localAdminAllowedAccountsJson": null, + "bootstrapTokenEnabled": false, + "unrestrictedModeEnabled": false, + "unrestrictedMode": false, + "entraAppRolesEnabled": false, + "diagnosticsLogPathsJson": null, + "diagnosticsBlobSasUrl": null, + "diagnosticsUploadMode": "Off", + "diagnosticsUploadDestination": "CustomerSas", + "sendTraceEvents": true, + "teamsWebhookUrl": null, + "teamsNotifyOnSuccess": true, + "teamsNotifyOnFailure": true, + "teamsNotifyOnStart": false, + "webhookProviderType": 0, + "webhookUrl": null, + "webhookNotifyOnSuccess": true, + "webhookNotifyOnFailure": true, + "webhookNotifyOnHardwareRejection": false, + "webhookNotifyOnStart": false, + "webhookCustomHeadersJson": null, + "notificationChannelsJson": null, + "slaTargetSuccessRate": null, + "slaTargetMaxDurationMinutes": null, + "slaTargetAppInstallSuccessRate": null, + "slaNotifyOnSuccessRateBreach": false, + "slaSuccessRateNotifyThreshold": null, + "slaNotifyOnDurationBreach": false, + "slaNotifyOnAppInstallBreach": false, + "slaNotifyOnConsecutiveFailures": false, + "slaConsecutiveFailureThreshold": 5 + } + }, + "agentConfig": { + "responseDefaults": { + "configVersion": 0, + "uploadIntervalSeconds": 10, + "selfDestructOnComplete": true, + "keepLogFile": false, + "enableGeoLocation": true, + "enableImeMatchLog": false, + "enableGatherRuleDebugLog": false, + "enableEspContinueAnywayObservation": false, + "collectors": null, + "gatherRules": [], + "imeLogPatterns": [], + "whiteGloveSealingPatternIds": [], + "maxAuthFailures": 5, + "authFailureTimeoutMinutes": 0, + "logLevel": "Info", + "rebootOnComplete": false, + "rebootDelaySeconds": 10, + "showEnrollmentSummary": false, + "enrollmentSummaryTimeoutSeconds": 60, + "enrollmentSummaryBrandingImageUrl": null, + "enrollmentSummaryLaunchRetrySeconds": 120, + "maxBatchSize": 100, + "diagnosticsUploadEnabled": false, + "diagnosticsUploadMode": "Off", + "diagnosticsLogPaths": [], + "analyzers": null, + "sendTraceEvents": true, + "unrestrictedMode": false, + "latestAgentSha256": null, + "latestAgentExeSha256": null, + "allowAgentDowngrade": false, + "ntpServer": "time.windows.com", + "enableTimezoneAutoSet": false, + "enableDoGroupIdAutoSet": false, + "deviceBlocked": false, + "deviceKillSignal": false, + "unblockAt": null, + "migrateToApiBaseUrl": null + }, + "collectorDefaults": { + "enablePerformanceCollector": true, + "performanceIntervalSeconds": 30, + "collectorIdleTimeoutMinutes": 15, + "desktopDetectorNoCandidateTimeoutMinutes": 10, + "enableAgentSelfMetrics": true, + "agentSelfMetricsIntervalSeconds": 60, + "helloWaitTimeoutSeconds": 30, + "agentMaxLifetimeMinutes": 360, + "enableDeliveryOptimizationCollector": true, + "deliveryOptimizationIntervalSeconds": 3, + "enableOfficeInstallDetector": true, + "officeInstallSettleSeconds": 10, + "enablePowerStateWatcher": true, + "stallProbeEnabled": true, + "stallProbeThresholdsMinutes": [ + 2, + 15, + 30, + 60, + 180 + ], + "stallProbeTraceIndices": [ + 2 + ], + "stallProbeSources": [ + "provisioning_registry", + "diagnostics_registry", + "eventlog", + "appworkload_log" + ], + "sessionStalledAfterProbeIndex": 4, + "modernDeploymentWatcherEnabled": true, + "modernDeploymentLogLevelMax": 3, + "modernDeploymentBackfillEnabled": true, + "modernDeploymentHarmlessEventIds": [ + 100, + 1005, + 1010 + ], + "modernDeploymentBackfillLookbackMinutes": 30, + "windowsUpdateWatcherEnabled": true, + "windowsUpdateTargetedEventIds": [ + 19, + 20, + 43, + 44 + ], + "windowsUpdateBackfillLookbackMinutes": 60, + "windowsUpdateChannelCensusEnabled": true, + "mdmRebootPolicyWatcherEnabled": true, + "mdmRebootPolicyBackfillLookbackMinutes": 60, + "enableSystemTimelineWatcher": true, + "systemEventBackfillLookbackMinutes": 1440 + }, + "analyzerDefaults": { + "enableLocalAdminAnalyzer": true, + "localAdminAllowedAccounts": [], + "enableSoftwareInventoryAnalyzer": false, + "enableIntegrityBypassAnalyzer": true, + "enableRealmJoinWatcher": false, + "keepAwakeDuringUserEsp": false, + "enableConsoleBypassDetection": true + } }, "sessionStatuses": [ "InProgress",