mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-10 11:49:55 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7633c02a1c | |||
| bd66ad7097 | |||
| be13d8f5ef | |||
| 8296fa7291 | |||
| 4faca8692b | |||
| ba715d5aa7 | |||
| ed73dc3a87 | |||
| 1e88d1db4b | |||
| f3778fbad6 | |||
| 1a85a75af1 | |||
| 5f13c71f1d | |||
| 041803e55c | |||
| eff89fd09f | |||
| 0cc35d2bae | |||
| 27cb286118 | |||
| c88e5411a8 | |||
| 5eba39f658 | |||
| 5f95648c73 | |||
| e4d527b6d5 | |||
| 258d7cd5f4 | |||
| 668ae14657 | |||
| 16bd82b148 |
+1
-1
Submodule .claude/commands updated: dbacde8e0d...9e518b0caf
+1
-1
Submodule internal updated: 1a3979773f...c4c3655cd0
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -65,6 +65,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -80,7 +92,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -94,7 +106,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -104,6 +117,7 @@
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -126,6 +140,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+23
-4
@@ -4103,10 +4103,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -4166,6 +4166,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -4181,7 +4193,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -4195,7 +4207,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -4205,6 +4218,7 @@
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -4227,6 +4241,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> run against the netstandard2.0 build of
|
||||
/// <see cref="LocalAdminAllowList"/> — the one the agent ships. The backend runs the same file against
|
||||
/// its net10.0 build, the portal against <c>allowedAccounts.ts</c>.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAllowListFixtureTests
|
||||
{
|
||||
private static IEnumerable<JToken> ParseCases()
|
||||
{
|
||||
var path = Path.Combine(RepoPaths.Fixtures("local-admin-allow-list"), "cases.json");
|
||||
return JObject.Parse(File.ReadAllText(path))["parse"]!;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> ParseEntries() => ParseCases().Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParseEntries))]
|
||||
public void Agent_parses_the_entry_as_the_shared_case_says(string entry)
|
||||
{
|
||||
var c = ParseCases().First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
|
||||
|
||||
if (c.Value<bool?>("ignored") == true)
|
||||
{
|
||||
Assert.False(parsed);
|
||||
return;
|
||||
}
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(c.Value<string>("pattern"), pattern);
|
||||
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-21
@@ -18,10 +18,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static readonly List<string> Allowed = new List<string>
|
||||
{
|
||||
"Administrator", "Guest", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0", "defaultuser1", "adm-*"
|
||||
};
|
||||
// The real built-in catalog plus one tenant glob.
|
||||
private static readonly List<LocalAdminAnalyzer.AllowedEntry> Allowed =
|
||||
LocalAdminAnalyzer.BuildAllowedEntries(new[] { "adm-*" }, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, bool disabled, int rid) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
@@ -126,7 +125,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
[Fact]
|
||||
public void Local_admin_member_missing_from_the_wmi_inventory_is_still_flagged()
|
||||
{
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough.
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough,
|
||||
// and the account counts as unexpected too, like any unknown administrator (score 80).
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
LocalMember("Administrator", 500),
|
||||
@@ -136,8 +136,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(
|
||||
new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, true, Machine, Allowed);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -191,26 +192,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Well_known_rid_does_not_cover_ordinary_accounts_or_foreign_sids()
|
||||
public void Machine_account_rid_is_read_only_from_local_machine_sids()
|
||||
{
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "500"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "501"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "503"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "504"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "502"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1001"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-5-32-544"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(null));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(""));
|
||||
Assert.Equal(500, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "500"));
|
||||
Assert.Equal(501, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "501"));
|
||||
Assert.Equal(1001, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "1001"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-5-32-544"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "x1"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(null!));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_named_like_a_builtin_but_with_an_ordinary_rid_is_still_matched_by_name_only()
|
||||
public void Localized_builtin_name_on_an_ordinary_rid_is_not_allowed()
|
||||
{
|
||||
// Name-based allowance is unchanged: the RID match only ADDS coverage.
|
||||
// Only the RID makes a built-in: an ordinary account merely named like a localized
|
||||
// built-in is no entry at all.
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Gast", disabled: false, rid: 1001)
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Roles on the allowed list: <c>name|user</c> and the standard-user built-ins (Guest,
|
||||
/// DefaultAccount, WDAGUtilityAccount by RID; kioskUser0, WsiAccount and other accounts carrying a
|
||||
/// built-in name by name) allow an account only while it is not an Administrators member. A
|
||||
/// restriction always wins; the four built-in accounts are decided by RID alone; profile folders
|
||||
/// have no role; a login allows a profile folder, never an account. All names are synthetic.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAnalyzerRoleTests
|
||||
{
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static List<LocalAdminAnalyzer.AllowedEntry> Entries(params string[] tenantEntries)
|
||||
=> LocalAdminAnalyzer.BuildAllowedEntries(tenantEntries, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, int rid, bool disabled = false) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
|
||||
private static LocalGroupMember Member(string name, int rid) =>
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\" + name, Sid = MachineSidPrefix + rid, SidUsage = 1 };
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountCheckResult Evaluate(
|
||||
IList<LocalAdminAnalyzer.LocalAccountInfo> accounts, IList<LocalGroupMember> admins, List<LocalAdminAnalyzer.AllowedEntry> entries,
|
||||
bool enumerated = true)
|
||||
=> LocalAdminAnalyzer.EvaluateAccounts(accounts, admins, enumerated, Machine, entries);
|
||||
|
||||
// -------------------------------------------------------------- WsiAccount (the field case)
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_as_standard_user_with_its_profile_folder_scores_zero()
|
||||
{
|
||||
// The field case: web sign-in enabled the account for the sign-in, it holds no admin
|
||||
// membership and has C:\Users\WsiAccount — before roles existed this scored 80 (Error).
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrator", 500, disabled: true),
|
||||
Account("DefaultAccount", 503, disabled: true),
|
||||
Account("defaultuser0", 1000, disabled: true),
|
||||
Account("Gast", 501, disabled: true),
|
||||
Account("WDAGUtilityAccount", 504, disabled: true),
|
||||
Account("WsiAccount", 1001, disabled: true),
|
||||
Account("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
new LocalGroupMember { DomainAndName = "S-1-12-1-11-22-33-44", Sid = "S-1-12-1-11-22-33-44", SidUsage = 8 },
|
||||
Member("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("ADM-*"));
|
||||
var score = LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
bypassNro: false, result.Unexpected, result.UnexpectedAdminMembers, new List<string> { "jane.doe" });
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(0, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_in_administrators_is_a_role_violation_even_while_disabled()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001, disabled: true) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
false, result.Unexpected, result.UnexpectedAdminMembers, new List<string>()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("WsiAccount")]
|
||||
[InlineData("wsiaccount")]
|
||||
[InlineData("Wsi*")]
|
||||
[InlineData("*")]
|
||||
public void Tenant_entries_cannot_lift_a_builtin_restriction(string tenantEntry)
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries(tenantEntry));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KioskUser0_in_administrators_is_a_role_violation()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("kioskUser0", 1003) },
|
||||
new List<LocalGroupMember> { Member("kioskUser0", 1003) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "kioskUser0" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- built-ins by RID
|
||||
|
||||
[Theory]
|
||||
[InlineData("Guest", 501)]
|
||||
[InlineData("Gast", 501)]
|
||||
[InlineData("Invité", 501)]
|
||||
[InlineData("DefaultAccount", 503)]
|
||||
[InlineData("WDAGUtilityAccount", 504)]
|
||||
public void Builtin_accounts_that_are_never_administrators_are_flagged_as_one(string name, int rid)
|
||||
{
|
||||
// A tenant entry naming the localized account (or even "*") cannot lift it either.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, rid, disabled: true) },
|
||||
new List<LocalGroupMember> { Member(name, rid) },
|
||||
Entries(name, "*"));
|
||||
|
||||
Assert.Equal(new[] { name }, result.Unexpected);
|
||||
Assert.Equal(new[] { name }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { name }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Administrator")]
|
||||
[InlineData("Administrateur")]
|
||||
[InlineData("Renamed_Admin")]
|
||||
public void The_builtin_administrator_is_allowed_by_rid_under_any_name(string name)
|
||||
{
|
||||
// "Admin*|user" must not turn the real Administrator into a finding: name entries never
|
||||
// decide the RID accounts — and the built-in name entry "Administrator|user" neither.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, 500) },
|
||||
new List<LocalGroupMember> { Member(name, 500) },
|
||||
Entries("Admin*|user", "Renamed*|user"));
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.True(result.BuiltInAdministratorEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinary_account_named_administrator_is_allowed_only_as_a_decoy()
|
||||
{
|
||||
// French device: the RID-500 account is "Administrateur", so "Administrator" is free.
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrateur", 500, disabled: true),
|
||||
Account("Administrator", 1004),
|
||||
};
|
||||
|
||||
var decoy = Evaluate(accounts, new List<LocalGroupMember> { Member("Administrateur", 500) }, Entries());
|
||||
Assert.Empty(decoy.Unexpected);
|
||||
|
||||
var impostor = Evaluate(accounts,
|
||||
new List<LocalGroupMember> { Member("Administrateur", 500), Member("Administrator", 1004) }, Entries());
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.RoleViolations);
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_oobe_account_may_be_an_administrator()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("defaultuser0", 1000) },
|
||||
new List<LocalGroupMember> { Member("defaultuser0", 1000) },
|
||||
Entries());
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_sid_lets_the_english_builtin_name_stand_in_for_the_rid()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Administrator", Disabled = false, Sid = null! },
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Guest", Disabled = false, Sid = null! },
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Administrator", Sid = null!, SidUsage = 1 },
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Guest", Sid = null!, SidUsage = 1 },
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries());
|
||||
|
||||
Assert.Equal(new[] { "Guest" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("Administrator", result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- tenant roles
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_entry_allows_the_account_until_it_is_an_administrator()
|
||||
{
|
||||
var accounts = new[] { Account("frontdesk", 1005) };
|
||||
|
||||
Assert.Empty(Evaluate(accounts, new List<LocalGroupMember>(), Entries("frontdesk|user")).Unexpected);
|
||||
|
||||
var admin = Evaluate(accounts, new List<LocalGroupMember> { Member("frontdesk", 1005) }, Entries("frontdesk|user"));
|
||||
Assert.Equal(new[] { "frontdesk" }, admin.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_glob_restricts_each_matching_account()
|
||||
{
|
||||
var accounts = new[] { Account("svc-print", 1006), Account("svc-scan", 1007) };
|
||||
|
||||
var result = Evaluate(accounts, new List<LocalGroupMember> { Member("svc-scan", 1007) }, Entries("svc-*|user"));
|
||||
|
||||
Assert.Equal(new[] { "svc-scan" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "svc-scan" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_restriction_wins_over_a_broader_entry_that_allows_more()
|
||||
{
|
||||
var accounts = new[] { Account("adm-kiosk", 1008), Account("adm-4711", 1009) };
|
||||
var admins = new List<LocalGroupMember> { Member("adm-kiosk", 1008), Member("adm-4711", 1009) };
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("adm-*", "adm-kiosk|user"));
|
||||
|
||||
Assert.Equal(new[] { "adm-kiosk" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("adm-4711", result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Membership_unknown_lets_a_standard_user_entry_act_like_a_plain_one()
|
||||
{
|
||||
// Enumeration failed: nobody is known to be an administrator, so nothing is a violation.
|
||||
var result = Evaluate(new[] { Account("WsiAccount", 1001) }, new List<LocalGroupMember>(), Entries(), enumerated: false);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.False(result.AdministratorsGroupEnumerated);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- folders are no accounts
|
||||
|
||||
[Fact]
|
||||
public void An_account_named_like_a_profile_folder_is_unexpected_while_the_folder_is_allowed()
|
||||
{
|
||||
var entries = Entries();
|
||||
|
||||
var result = Evaluate(new[] { Account("Public", 1010) }, new List<LocalGroupMember> { Member("Public", 1010) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "Public" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "Public" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(LocalAdminAnalyzer.AccountDecision.Unlisted,
|
||||
LocalAdminAnalyzer.Decide("Public", MachineSidPrefix + "1010", isAdmin: false, entries));
|
||||
Assert.Contains(entries, e => e.Pattern == "Public" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- accounts WMI did not return
|
||||
|
||||
[Fact]
|
||||
public void Administrators_members_missing_from_wmi_are_judged_as_administrators()
|
||||
{
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
Member("WsiAccount", 1001),
|
||||
Member("Gast", 501),
|
||||
Member("adm-4711", 1009),
|
||||
};
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, Entries("adm-*"));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.RoleViolations);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_member_wmi_already_returned_is_not_judged_twice()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("backdoor", 1011) },
|
||||
new List<LocalGroupMember> { Member("backdoor", 1011) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_login_never_allows_an_account_not_even_when_wmi_failed()
|
||||
{
|
||||
// WMI failed: the split cannot recognise "backdoor" as local and allows it dynamically.
|
||||
// The dynamic entry is folder-only, so the signed-in backdoor administrator is still found.
|
||||
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, null!);
|
||||
var entries = Entries();
|
||||
entries.AddRange(split.Allowed.Select(u => new LocalAdminAnalyzer.AllowedEntry(u, LocalAccountRole.Any, profileFolderOnly: true)));
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), new List<LocalGroupMember> { Member("backdoor", 1011) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- the entry list
|
||||
|
||||
[Fact]
|
||||
public void Entries_carry_the_builtins_and_skip_invalid_tenant_entries()
|
||||
{
|
||||
var ignored = new List<string>();
|
||||
var entries = LocalAdminAnalyzer.BuildAllowedEntries(
|
||||
new[] { "kiosk|user", "x|admin", "|user", "legacy*", "CONTOSO\\admin", " ", "Guest|user" }, ignored);
|
||||
|
||||
Assert.Equal(new[] { "x|admin", "|user" }, ignored);
|
||||
var accounts = entries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList();
|
||||
Assert.Contains("WsiAccount|user", accounts);
|
||||
Assert.Contains("kioskUser0|user", accounts);
|
||||
Assert.Contains("defaultuser0", accounts);
|
||||
Assert.Contains("kiosk|user", accounts);
|
||||
Assert.Contains("legacy*", accounts);
|
||||
Assert.Contains("CONTOSO\\admin", accounts);
|
||||
Assert.Single(accounts, a => a == "Guest|user");
|
||||
Assert.DoesNotContain("Public", accounts);
|
||||
Assert.Contains(entries, e => e.Pattern == "All Users" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- scoring
|
||||
|
||||
[Fact]
|
||||
public void Score_follows_the_signals()
|
||||
{
|
||||
var none = new List<string>();
|
||||
var backdoor = new List<string> { "backdoor" };
|
||||
|
||||
Assert.Equal(0, LocalAdminAnalyzer.ComputeConfidenceScore(false, none, none, backdoor));
|
||||
Assert.Equal(20, LocalAdminAnalyzer.ComputeConfidenceScore(true, none, none, none));
|
||||
Assert.Equal(40, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, backdoor, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, new List<string> { "BACKDOOR" }));
|
||||
Assert.Equal(100, LocalAdminAnalyzer.ComputeConfidenceScore(true, backdoor, backdoor, backdoor));
|
||||
}
|
||||
}
|
||||
}
|
||||
+237
-127
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
@@ -36,21 +37,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// (<c>builtin_administrator_enabled</c>) but not scored: whether OOBE enables it
|
||||
/// transiently is not established, so the field first collects evidence.
|
||||
///
|
||||
/// Built-in accounts are recognised by well-known RID (500/501/503/504 on the machine SID)
|
||||
/// in addition to their English names, so localized installs (Gast, Invité, Administrateur)
|
||||
/// do not flag their own disabled Guest/Administrator.
|
||||
/// Allowed list (<see cref="LocalAdminAllowList"/> built-ins plus the tenant's entries): an entry
|
||||
/// allows an account in any role, or — <c>name|user</c> — only while it is not an
|
||||
/// Administrators member. A restriction always wins over an entry that allows more. The four
|
||||
/// built-in accounts are decided by their well-known RID alone (500 any role; 501/503/504 never
|
||||
/// an administrator), so localized names (Gast, Administrateur) are recognised and no name entry
|
||||
/// can lift or impose a role on them. Profile folders have no role.
|
||||
///
|
||||
/// At shutdown the logged-in users are allowed dynamically — but only identities that are
|
||||
/// NOT local SAM accounts (Entra / domain users, whose profile folder is the only artifact
|
||||
/// they leave). A logged-in local account is exactly the delta this analyzer exists to
|
||||
/// catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// At shutdown the logged-in users are allowed dynamically — as profile folders only, and only
|
||||
/// identities that are NOT local SAM accounts (Entra / domain users, whose profile folder is the
|
||||
/// only artifact they leave). A logged-in local account is exactly the delta this analyzer exists
|
||||
/// to catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// adversary's control); it is reported as <c>logged_in_local_accounts</c>.
|
||||
///
|
||||
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
|
||||
/// enabling delta detection between pre- and post-enrollment state.
|
||||
///
|
||||
/// Tenant-supplied allowed accounts may contain glob wildcards (e.g. "adm-*");
|
||||
/// see <see cref="MatchesAllowedEntry"/>.
|
||||
/// </summary>
|
||||
public class LocalAdminAnalyzer : IAgentAnalyzer
|
||||
{
|
||||
@@ -58,26 +59,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<string> _allowedAccounts;
|
||||
|
||||
// Built-in accounts and profile folders always present on a freshly imaged Windows device.
|
||||
// "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts.
|
||||
// "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment.
|
||||
private static readonly List<string> BuiltInAllowedAccounts = new List<string>
|
||||
{
|
||||
"Administrator",
|
||||
"Guest",
|
||||
"DefaultAccount",
|
||||
"WDAGUtilityAccount",
|
||||
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
|
||||
"defaultuser1", // Sometimes seen in OOBE, but not always present
|
||||
"defaultuser2", // Sometimes seen in OOBE, but not always present
|
||||
"kioskUser0", // Assigned Access (kiosk profile) autologon account, created by Windows
|
||||
"Public", // Profile folder (not a user account)
|
||||
"Default", // Default user profile template
|
||||
"Default User", // Symlink to Default in some Windows versions
|
||||
"All Users" // Junction pointing to C:\ProgramData, always present
|
||||
};
|
||||
private readonly List<AllowedEntry> _allowedEntries;
|
||||
|
||||
public string Name => "LocalAdminAnalyzer";
|
||||
|
||||
@@ -97,19 +79,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_startupGate = startupGate;
|
||||
|
||||
// Tenant-supplied accounts are additive (union with built-ins, not replacement)
|
||||
_allowedAccounts = new List<string>(BuiltInAllowedAccounts);
|
||||
if (allowedAccounts != null)
|
||||
{
|
||||
foreach (var account in allowedAccounts)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(account) &&
|
||||
!_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_allowedAccounts.Add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
var ignored = new List<string>();
|
||||
_allowedEntries = BuildAllowedEntries(allowedAccounts, ignored);
|
||||
foreach (var entry in ignored)
|
||||
_logger.Warning($"{Name}: Allowed-list entry '{entry}' is not valid (expected 'name' or 'name|user') — ignored, it allows nothing");
|
||||
}
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
@@ -149,46 +122,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
foreach (var user in loggedIn.LocalAccounts)
|
||||
_logger.Warning($"{Name}: Logged-in user '{user}' is a local account — not allowed dynamically");
|
||||
|
||||
// Build effective allowed list: static + dynamic (non-local logged-in users at shutdown)
|
||||
var effectiveAllowed = _allowedAccounts;
|
||||
// Effective list: static entries + the profile folders of non-local logged-in users at
|
||||
// shutdown. Folder-only: a login never allows a local ACCOUNT — not even when the WMI
|
||||
// inventory failed and the split above could not recognise a local name.
|
||||
var effectiveEntries = _allowedEntries;
|
||||
if (loggedIn.Allowed.Count > 0)
|
||||
{
|
||||
effectiveAllowed = new List<string>(_allowedAccounts);
|
||||
effectiveEntries = new List<AllowedEntry>(_allowedEntries);
|
||||
foreach (var user in loggedIn.Allowed)
|
||||
{
|
||||
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
|
||||
if (!effectiveEntries.Any(e => string.Equals(e.Pattern, user, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
effectiveAllowed.Add(user);
|
||||
_logger.Info($"{Name}: Dynamically allowing logged-in user: {user}");
|
||||
effectiveEntries.Add(new AllowedEntry(user, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
_logger.Info($"{Name}: Dynamically allowing profile folder of logged-in user: {user}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bypassNroResult = CheckBypassNroRegistry();
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveAllowed);
|
||||
var profilesResult = CheckUserProfiles(effectiveAllowed);
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveEntries);
|
||||
var profilesResult = CheckUserProfiles(effectiveEntries);
|
||||
|
||||
int confidenceScore = 0;
|
||||
|
||||
if (bypassNroResult.Value == 1)
|
||||
confidenceScore += 20;
|
||||
|
||||
if (accountsResult.Unexpected.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// An unexpected account that is also a member of Administrators is the
|
||||
// actual backdoor (enabled or dormant) — high indicator on its own.
|
||||
if (accountsResult.UnexpectedAdminMembers.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// Profile overlap: unexpected account AND matching C:\Users folder
|
||||
bool profileOverlap = accountsResult.Unexpected.Any(a =>
|
||||
profilesResult.Unexpected.Any(p =>
|
||||
string.Equals(a, p, StringComparison.OrdinalIgnoreCase)));
|
||||
if (profileOverlap)
|
||||
confidenceScore += 40;
|
||||
|
||||
confidenceScore = Math.Min(confidenceScore, 100);
|
||||
var confidenceScore = ComputeConfidenceScore(
|
||||
bypassNroResult.Value == 1, accountsResult.Unexpected, accountsResult.UnexpectedAdminMembers, profilesResult.Unexpected);
|
||||
|
||||
EventSeverity severity;
|
||||
string findingLabel;
|
||||
@@ -219,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
$"bypassNro={bypassNroResult.Value}, " +
|
||||
$"unexpectedAccounts={accountsResult.Unexpected.Count}, " +
|
||||
$"unexpectedAdminMembers={accountsResult.UnexpectedAdminMembers.Count}, " +
|
||||
$"roleViolations={accountsResult.RoleViolations.Count}, " +
|
||||
$"adminGroupEnumerated={accountsResult.AdministratorsGroupEnumerated}, " +
|
||||
$"builtinAdministratorEnabled={accountsResult.BuiltInAdministratorEnabled?.ToString() ?? "unknown"}, " +
|
||||
$"unexpectedProfiles={profilesResult.Unexpected.Count}");
|
||||
@@ -230,7 +187,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{ "finding", findingLabel },
|
||||
{ "triggered_at", trigger },
|
||||
{ "enrollment_phase_at_check", phase.ToString() },
|
||||
{ "allowed_accounts", effectiveAllowed },
|
||||
// Account entries in canonical form ("Guest|user"); the folder-only entries are
|
||||
// constant or listed in dynamically_allowed_users.
|
||||
{ "allowed_accounts", _allowedEntries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList() },
|
||||
{ "dynamically_allowed_users", loggedIn.Allowed },
|
||||
{ "logged_in_local_accounts", loggedIn.LocalAccounts },
|
||||
{ "checks", new Dictionary<string, object>
|
||||
@@ -243,6 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
},
|
||||
{ "unexpected_accounts", accountsResult.Unexpected },
|
||||
{ "unexpected_admin_members", accountsResult.UnexpectedAdminMembers },
|
||||
{ "role_violations", accountsResult.RoleViolations },
|
||||
{ "unexpected_profiles", profilesResult.Unexpected },
|
||||
{ "accounts_checked", accountsResult.AllChecked },
|
||||
{ "account_details", accountsResult.AccountDetails },
|
||||
@@ -299,31 +259,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence 0-100: BypassNRO +20, any unexpected account +40, any unexpected Administrators
|
||||
/// member +40 (the actual backdoor, enabled or dormant; a role violation is one), an unexpected
|
||||
/// account with a C:\Users folder of the same name +40. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static int ComputeConfidenceScore(
|
||||
bool bypassNro, IList<string> unexpectedAccounts, IList<string> unexpectedAdminMembers, IList<string> unexpectedProfiles)
|
||||
{
|
||||
var score = 0;
|
||||
if (bypassNro)
|
||||
score += 20;
|
||||
if (unexpectedAccounts.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAdminMembers.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAccounts.Any(a => unexpectedProfiles.Any(p => string.Equals(a, p, StringComparison.OrdinalIgnoreCase))))
|
||||
score += 40;
|
||||
return Math.Min(score, 100);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Allowed-list matching
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Matches an account or profile-folder name against one allowed-list entry.
|
||||
/// Entries containing <c>*</c> or <c>?</c> are glob patterns (same semantics as the
|
||||
/// Matches an account or profile-folder name against one allowed-list pattern.
|
||||
/// Patterns containing <c>*</c> or <c>?</c> are globs (same semantics as the
|
||||
/// hardware whitelist: <c>*</c> = any run, <c>?</c> = one char, case-insensitive);
|
||||
/// all other entries match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// all other patterns match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// <c>?</c> are invalid characters in Windows account names.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool MatchesAllowedEntry(string name, string entry)
|
||||
internal static bool MatchesAllowedEntry(string name, string pattern)
|
||||
{
|
||||
if (entry.IndexOf('*') < 0 && entry.IndexOf('?') < 0)
|
||||
return string.Equals(entry, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (pattern.IndexOf('*') < 0 && pattern.IndexOf('?') < 0)
|
||||
return string.Equals(pattern, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var regexPattern = "^" + Regex.Escape(entry)
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
try
|
||||
{
|
||||
// Timeout guards against pathological patterns (ReDoS)
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
|
||||
// Timeout guards against pathological patterns (ReDoS). CultureInvariant: under a
|
||||
// Turkish culture "i" and "I" are no case pair, so "WLapsAdmin*" missed "WLAPSADMIN01".
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
@@ -331,39 +313,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string name, List<string> allowedAccounts)
|
||||
/// <summary>
|
||||
/// The effective static list: the built-in account-name and profile-folder entries of
|
||||
/// <see cref="LocalAdminAllowList.BuiltIns"/> (RID entries are decided by RID, see
|
||||
/// <see cref="Decide"/>) plus the tenant's entries. An entry that does not parse is added to
|
||||
/// <paramref name="ignored"/> and allows nothing. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static List<AllowedEntry> BuildAllowedEntries(IEnumerable<string> tenantEntries, List<string> ignored)
|
||||
{
|
||||
return allowedAccounts.Any(a => MatchesAllowedEntry(name, a));
|
||||
var entries = new List<AllowedEntry>();
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match == LocalAdminBuiltInMatch.AccountName)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, builtIn.Role, profileFolderOnly: false));
|
||||
else if (builtIn.Match == LocalAdminBuiltInMatch.ProfileFolder)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
}
|
||||
|
||||
foreach (var raw in tenantEntries ?? Enumerable.Empty<string>())
|
||||
{
|
||||
if (LocalAdminAllowList.TryParseEntry(raw, out var pattern, out var role))
|
||||
AddDistinct(entries, new AllowedEntry(pattern, role, profileFolderOnly: false));
|
||||
else if (!string.IsNullOrWhiteSpace(raw))
|
||||
ignored.Add(raw);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void AddDistinct(List<AllowedEntry> entries, AllowedEntry entry)
|
||||
{
|
||||
if (!entries.Any(e => e.ProfileFolderOnly == entry.ProfileFolderOnly
|
||||
&& string.Equals(e.Display, entry.Display, StringComparison.OrdinalIgnoreCase)))
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known built-in accounts of the local machine domain, recognised by RID so that
|
||||
/// localized names (Gast, Invité, Administrateur, …) are not flagged:
|
||||
/// 500 Administrator, 501 Guest, 503 DefaultAccount, 504 WDAGUtilityAccount.
|
||||
/// Only S-1-5-21-… SIDs qualify (the WMI query is already restricted to local accounts).
|
||||
/// Decides one local account. The built-in accounts are decided by their RID alone: 500 in any
|
||||
/// role, 501/503/504 never as an administrator — no name entry, built-in or tenant, lifts or
|
||||
/// imposes a role on them. Without a SID (a WMI corner case) the English name stands in for the
|
||||
/// RID. Every other account is allowed when an account entry matches it, unless it is an
|
||||
/// administrator and ANY matching entry is standard-user only: a restriction always wins.
|
||||
/// Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static AccountDecision Decide(string name, string sid, bool isAdmin, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var builtIn = FindBuiltInRidEntry(name, sid);
|
||||
if (builtIn != null)
|
||||
return isAdmin && builtIn.Role == LocalAccountRole.StandardUser ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
|
||||
var covered = false;
|
||||
var restricted = false;
|
||||
foreach (var entry in allowed)
|
||||
{
|
||||
if (entry.ProfileFolderOnly || !MatchesAllowedEntry(name, entry.Pattern))
|
||||
continue;
|
||||
covered = true;
|
||||
if (entry.Role == LocalAccountRole.StandardUser)
|
||||
restricted = true;
|
||||
}
|
||||
|
||||
if (!covered)
|
||||
return AccountDecision.Unlisted;
|
||||
return isAdmin && restricted ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
}
|
||||
|
||||
private static LocalAdminBuiltInEntry FindBuiltInRidEntry(string name, string sid)
|
||||
{
|
||||
var rid = MachineAccountRid(sid);
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match != LocalAdminBuiltInMatch.Rid)
|
||||
continue;
|
||||
var matches = rid.HasValue
|
||||
? builtIn.Rid == rid.Value
|
||||
: string.IsNullOrEmpty(sid) && string.Equals(builtIn.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (matches)
|
||||
return builtIn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RID (the last sub-authority) of a local-machine account SID (<c>S-1-5-21-…</c>; the WMI query
|
||||
/// and the member filter already restrict to local accounts), else null.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool IsWellKnownBuiltInRid(string sid)
|
||||
internal static int? MachineAccountRid(string sid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sid) || !sid.StartsWith("S-1-5-21-", StringComparison.Ordinal))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
var dash = sid.LastIndexOf('-');
|
||||
if (dash < 0 || dash == sid.Length - 1)
|
||||
return false;
|
||||
|
||||
switch (sid.Substring(dash + 1))
|
||||
{
|
||||
case "500":
|
||||
case "501":
|
||||
case "503":
|
||||
case "504":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return int.TryParse(sid.Substring(dash + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var rid)
|
||||
? rid
|
||||
: (int?)null;
|
||||
}
|
||||
|
||||
/// <summary>A profile folder has no role: any entry of the same name allows it.</summary>
|
||||
private static bool IsAllowedFolder(string folderName, IReadOnlyList<AllowedEntry> allowed)
|
||||
=> allowed.Any(e => MatchesAllowedEntry(folderName, e.Pattern));
|
||||
|
||||
/// <summary>
|
||||
/// Splits the logged-in user names into those that may be allowed dynamically (not a
|
||||
/// local SAM account — Entra / domain identities whose only artifact is a profile
|
||||
@@ -481,7 +531,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, List<string> allowedAccounts)
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
// Administrators-group membership is read from the SAM, independent of WMI and of
|
||||
// the enabled state — a dormant backdoor with admin rights is still a member.
|
||||
@@ -489,13 +539,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
if (!group.Succeeded)
|
||||
_logger.Warning($"{Name}: Failed to enumerate members of '{group.GroupName}': NET_API_STATUS={group.ErrorCode}");
|
||||
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowedAccounts);
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowed);
|
||||
result.AdministratorsGroupErrorCode = group.ErrorCode;
|
||||
|
||||
foreach (var name in result.Unexpected)
|
||||
_logger.Debug($"{Name}: Unexpected local account: {name}");
|
||||
foreach (var name in result.UnexpectedAdminMembers)
|
||||
_logger.Debug($"{Name}: Unexpected Administrators member: {name}");
|
||||
foreach (var name in result.RoleViolations)
|
||||
_logger.Debug($"{Name}: Allowed only as a standard user but an Administrators member: {name}");
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -508,10 +560,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// - every local account is checked, disabled ones included (state is reported);
|
||||
/// - a local account is an Administrators member when its SID matches a group member,
|
||||
/// or (SID unavailable) its name matches a member of the local machine domain;
|
||||
/// - an account is allowed by name (allowed list, globs) or by well-known RID
|
||||
/// (<see cref="IsWellKnownBuiltInRid"/>) — localized built-ins are never flagged;
|
||||
/// - <c>UnexpectedAdminMembers</c> = unexpected accounts holding membership, plus local
|
||||
/// members not on the allowed list that the WMI inventory did not return at all;
|
||||
/// - each account is decided by <see cref="Decide"/>; an account no entry allows, or one
|
||||
/// that breaks a standard-user entry, is unexpected (and an unexpected admin when it
|
||||
/// holds membership; the latter also a role violation);
|
||||
/// - local members the WMI inventory did not return at all are decided as administrators
|
||||
/// and land in both unexpected lists, like any unknown administrator;
|
||||
/// - with membership unknown (enumeration failed) nobody counts as an administrator, so a
|
||||
/// standard-user entry acts like a plain one;
|
||||
/// - members outside the local machine domain (Entra role SIDs, domain groups) are
|
||||
/// listed for delta comparison but never flagged — they are expected on joined devices.
|
||||
/// </summary>
|
||||
@@ -520,7 +575,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
IList<LocalGroupMember> adminMembers,
|
||||
bool adminGroupEnumerated,
|
||||
string machineName,
|
||||
List<string> allowedAccounts)
|
||||
IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var result = new LocalAccountCheckResult { AdministratorsGroupEnumerated = adminGroupEnumerated };
|
||||
|
||||
@@ -539,45 +594,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
var inventorySids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var inventoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var account in accounts ?? new List<LocalAccountInfo>())
|
||||
{
|
||||
var isAdmin = (!string.IsNullOrEmpty(account.Sid) && adminSids.Contains(account.Sid))
|
||||
|| adminLocalNames.Contains(account.Name);
|
||||
var rid = MachineAccountRid(account.Sid);
|
||||
|
||||
if (!string.IsNullOrEmpty(account.Sid))
|
||||
inventorySids.Add(account.Sid);
|
||||
inventoryNames.Add(account.Name);
|
||||
|
||||
result.AllChecked.Add(account.Name);
|
||||
result.AccountDetails.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "name", account.Name },
|
||||
{ "disabled", account.Disabled },
|
||||
{ "administrators_member", isAdmin }
|
||||
{ "administrators_member", isAdmin },
|
||||
{ "rid", rid }
|
||||
});
|
||||
|
||||
if (account.Sid != null && account.Sid.EndsWith("-500", StringComparison.Ordinal))
|
||||
if (rid == 500)
|
||||
result.BuiltInAdministratorEnabled = !account.Disabled;
|
||||
|
||||
if (IsWellKnownBuiltInRid(account.Sid) || IsAllowed(account.Name, allowedAccounts))
|
||||
continue;
|
||||
|
||||
result.Unexpected.Add(account.Name);
|
||||
if (isAdmin)
|
||||
result.UnexpectedAdminMembers.Add(account.Name);
|
||||
Record(result, account.Name, Decide(account.Name, account.Sid, isAdmin, allowed), isAdmin);
|
||||
}
|
||||
|
||||
// Local members the WMI inventory did not surface (WMI failure, or an account the
|
||||
// provider does not list) — still an unexpected admin when not on the allowed list.
|
||||
// provider does not list): the SAM is authoritative for membership.
|
||||
foreach (var member in adminLocalMembers)
|
||||
{
|
||||
var name = member.Name;
|
||||
if (IsWellKnownBuiltInRid(member.Sid) || IsAllowed(name, allowedAccounts))
|
||||
var returnedByWmi = !string.IsNullOrEmpty(member.Sid)
|
||||
? inventorySids.Contains(member.Sid)
|
||||
: inventoryNames.Contains(member.Name);
|
||||
if (returnedByWmi)
|
||||
continue;
|
||||
if (result.UnexpectedAdminMembers.Any(u => string.Equals(u, name, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
result.UnexpectedAdminMembers.Add(name);
|
||||
Record(result, member.Name, Decide(member.Name, member.Sid, isAdmin: true, allowed), isAdmin: true);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Record(LocalAccountCheckResult result, string name, AccountDecision decision, bool isAdmin)
|
||||
{
|
||||
if (decision == AccountDecision.Allowed)
|
||||
return;
|
||||
AddOnce(result.Unexpected, name);
|
||||
if (isAdmin)
|
||||
AddOnce(result.UnexpectedAdminMembers, name);
|
||||
if (decision == AccountDecision.RoleViolation)
|
||||
AddOnce(result.RoleViolations, name);
|
||||
}
|
||||
|
||||
private static void AddOnce(List<string> names, string name)
|
||||
{
|
||||
if (!names.Any(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)))
|
||||
names.Add(name);
|
||||
}
|
||||
|
||||
private static bool IsLocalMember(LocalGroupMember member, string machineName)
|
||||
{
|
||||
var domain = member.Domain;
|
||||
@@ -586,7 +661,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return string.Equals(domain, machineName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private UserProfileCheckResult CheckUserProfiles(List<string> allowedAccounts)
|
||||
private UserProfileCheckResult CheckUserProfiles(IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var allFound = new List<string>();
|
||||
var unexpected = new List<string>();
|
||||
@@ -616,7 +691,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
allFound.Add(folderName);
|
||||
|
||||
if (!IsAllowed(folderName, allowedAccounts))
|
||||
if (!IsAllowedFolder(folderName, allowed))
|
||||
{
|
||||
unexpected.Add(folderName);
|
||||
_logger.Debug($"{Name}: Unexpected profile folder: {folderName}");
|
||||
@@ -740,7 +815,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// <summary>Unexpected accounts that are members of the Administrators group.</summary>
|
||||
public List<string> UnexpectedAdminMembers { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member.</summary>
|
||||
/// <summary>Administrators members that an entry allows only as a standard user (subset of <see cref="UnexpectedAdminMembers"/>).</summary>
|
||||
public List<string> RoleViolations { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member, rid.</summary>
|
||||
public List<Dictionary<string, object>> AccountDetails { get; set; } = new List<Dictionary<string, object>>();
|
||||
|
||||
/// <summary>Every Administrators member (DOMAIN\name or SID), local or not — for delta comparison.</summary>
|
||||
@@ -753,6 +831,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
public bool? BuiltInAdministratorEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One effective allowed-list entry: a name or glob and what it may allow.</summary>
|
||||
internal sealed class AllowedEntry
|
||||
{
|
||||
public AllowedEntry(string pattern, LocalAccountRole role, bool profileFolderOnly)
|
||||
{
|
||||
Pattern = pattern;
|
||||
Role = role;
|
||||
ProfileFolderOnly = profileFolderOnly;
|
||||
}
|
||||
|
||||
public string Pattern { get; }
|
||||
|
||||
public LocalAccountRole Role { get; }
|
||||
|
||||
/// <summary>Allows the C:\Users folder of this name, never a local account.</summary>
|
||||
public bool ProfileFolderOnly { get; }
|
||||
|
||||
/// <summary>Canonical entry text as the event lists it ("Guest|user").</summary>
|
||||
public string Display => LocalAdminAllowList.Format(Pattern, Role);
|
||||
}
|
||||
|
||||
internal enum AccountDecision
|
||||
{
|
||||
Allowed,
|
||||
|
||||
/// <summary>No entry allows the account.</summary>
|
||||
Unlisted,
|
||||
|
||||
/// <summary>An administrator that a standard-user entry (or a built-in RID) allows only as a standard user.</summary>
|
||||
RoleViolation,
|
||||
}
|
||||
|
||||
/// <summary>Logged-in users at shutdown, split by whether the name is a local SAM account.</summary>
|
||||
internal sealed class LoggedInUserSplit
|
||||
{
|
||||
|
||||
@@ -93,6 +93,39 @@ public class AuthFunctionTests
|
||||
Assert.Equal(pending, (bool)ToDynamic(result.Body).dpaAcceptancePending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_MemberWithARoleGetsTheCompanyName()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.CompanyName = " Contoso Ltd. ";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: ViewerRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Contoso Ltd.", (string)ToDynamic(result.Body).companyName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_RolelessMemberGetsNoCompanyName()
|
||||
{
|
||||
// Without a role the caller sees only the Progress Portal; the config GET would refuse them.
|
||||
var config = DefaultConfig();
|
||||
config.CompanyName = "Contoso Ltd.";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null((string?)ToDynamic(result.Body).companyName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("platform", "platform")]
|
||||
[InlineData("Platform", "platform")]
|
||||
|
||||
@@ -202,6 +202,28 @@ public class ConfigBackupHookTests
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_SetupGuideMarkers_NoSnapshot()
|
||||
{
|
||||
// The setup guide's markers would otherwise push real settings out of the two backup slots.
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.SetupGuideSettingsReviewedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideHardwareReviewedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideDismissedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideDismissedBy = "admin@contoso.com";
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "setup-guide", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AddOnPermissionState_NoSnapshot()
|
||||
{
|
||||
|
||||
@@ -772,6 +772,35 @@ public class ConfigWireParityTests
|
||||
|
||||
// ---- UpdateTenantConfiguration -------------------------------------------------------
|
||||
|
||||
// ---- RecordSetupGuideStep ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SetupGuideStateResponse_matches_the_marker_shape()
|
||||
{
|
||||
DateTime? settingsReviewedAt = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
|
||||
DateTime? hardwareReviewedAt = settingsReviewedAt.Value.AddMinutes(3);
|
||||
DateTime? dismissedAt = null;
|
||||
string? dismissedBy = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
settingsReviewedAt,
|
||||
hardwareReviewedAt,
|
||||
dismissedAt,
|
||||
dismissedBy
|
||||
},
|
||||
new SetupGuideStateResponse
|
||||
{
|
||||
Success = true,
|
||||
SettingsReviewedAt = settingsReviewedAt,
|
||||
HardwareReviewedAt = hardwareReviewedAt,
|
||||
DismissedAt = null,
|
||||
DismissedBy = null
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.DependencyCollector;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Credential URLs never reach a dependency row (K20, D-424): inside a request of a marked client the row
|
||||
/// keeps scheme + host and the method, whatever host the URL names; outside it nothing changes; the mark
|
||||
/// does not leak into the caller's flow; the real collector pipeline carries the mark to the processor;
|
||||
/// and both credential clients (webhooks, push) carry the mark.
|
||||
/// </summary>
|
||||
public class CredentialUrlRedactionProcessorTests
|
||||
{
|
||||
private sealed class CapturingProcessor : ITelemetryProcessor
|
||||
{
|
||||
public List<ITelemetry> Received { get; } = new();
|
||||
public void Process(ITelemetry item) => Received.Add(item);
|
||||
}
|
||||
|
||||
private sealed class CapturingChannel : ITelemetryChannel
|
||||
{
|
||||
private readonly List<ITelemetry> _items = new();
|
||||
public IReadOnlyList<ITelemetry> Items { get { lock (_items) return _items.ToList(); } }
|
||||
public bool? DeveloperMode { get; set; }
|
||||
public string? EndpointAddress { get; set; }
|
||||
public void Send(ITelemetry item) { lock (_items) _items.Add(item); }
|
||||
public void Flush() { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Stands in for the collector: tracks a dependency row from inside the request's flow.</summary>
|
||||
private sealed class TrackingInnerHandler(ITelemetryProcessor processor, DependencyTelemetry row) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
processor.Process(row);
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
}
|
||||
}
|
||||
|
||||
private static DependencyTelemetry Row(string url, string method = "POST")
|
||||
=> new() { Type = "Http", Data = url, Name = $"{method} {new Uri(url).AbsolutePath}", Target = new Uri(url).Authority };
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
|
||||
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
|
||||
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
|
||||
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
|
||||
[InlineData("https://discord.com/api/webhooks/123456/discord-token", "https://discord.com")]
|
||||
[InlineData("https://hooks.slack.com/services/T000/B000/slack-secret", "https://hooks.slack.com")]
|
||||
[InlineData("https://default00000000.00.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/abc/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers&sv=1.0&sig=signature", "https://default00000000.00.environment.api.powerplatform.com")]
|
||||
[InlineData("https://tickets.example.test/hooks/intake?apikey=generic-secret", "https://tickets.example.test")]
|
||||
public void Redact_keeps_scheme_host_and_method_only(string url, string expected)
|
||||
{
|
||||
var dependency = Row(url);
|
||||
|
||||
CredentialUrlRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal(expected, dependency.Data);
|
||||
Assert.Equal("POST " + expected, dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_of_an_unparseable_url_keeps_only_the_target_host()
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = "not a url /api/webhooks/1/token", Name = "POST /api/webhooks/1/token", Target = "discord.com" };
|
||||
|
||||
CredentialUrlRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal("discord.com", dependency.Data);
|
||||
Assert.Equal("POST discord.com", dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_outside_a_marked_request_are_untouched()
|
||||
{
|
||||
var next = new CapturingProcessor();
|
||||
var processor = new CredentialUrlRedactionProcessor(next);
|
||||
var dependency = Row("https://graph.microsoft.com/v1.0/devices?$filter=x", "GET");
|
||||
|
||||
processor.Process(dependency);
|
||||
|
||||
Assert.Same(dependency, Assert.Single(next.Received));
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices?$filter=x", dependency.Data);
|
||||
Assert.Equal("GET /v1.0/devices", dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_mark_covers_the_request_and_ends_with_it()
|
||||
{
|
||||
var next = new CapturingProcessor();
|
||||
var processor = new CredentialUrlRedactionProcessor(next);
|
||||
var inside = Row("https://discord.com/api/webhooks/123456/discord-token");
|
||||
using var client = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler
|
||||
{
|
||||
InnerHandler = new TrackingInnerHandler(processor, inside),
|
||||
});
|
||||
|
||||
await client.PostAsync("https://discord.com/api/webhooks/123456/discord-token", new StringContent("{}"));
|
||||
var after = Row("https://graph.microsoft.com/v1.0/devices", "GET");
|
||||
processor.Process(after);
|
||||
|
||||
Assert.Equal("https://discord.com", inside.Data);
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices", after.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_real_collector_pipeline_carries_the_mark_to_the_processor()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
using var configuration = new TelemetryConfiguration
|
||||
{
|
||||
TelemetryChannel = channel,
|
||||
ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000",
|
||||
};
|
||||
configuration.TelemetryProcessorChainBuilder.Use(next => new CredentialUrlRedactionProcessor(next)).Build();
|
||||
using var collector = new DependencyTrackingTelemetryModule();
|
||||
collector.Initialize(configuration);
|
||||
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var server = AnswerNoContentAsync(listener, connections: 2);
|
||||
var secretPath = $"http://127.0.0.1:{port}/api/webhooks/123456/discord-token";
|
||||
|
||||
using (var marked = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler { InnerHandler = new SocketsHttpHandler() }))
|
||||
(await marked.PostAsync(secretPath, new StringContent("{}"))).Dispose();
|
||||
using (var plain = new HttpClient(new SocketsHttpHandler()))
|
||||
(await plain.PostAsync(secretPath + "?unmarked=1", new StringContent("{}"))).Dispose();
|
||||
await server;
|
||||
|
||||
var rows = channel.Items.OfType<DependencyTelemetry>().Where(d => d.Target?.EndsWith(":" + port) == true).ToList();
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Contains(rows, d => d.Data == "http://127.0.0.1" && d.Name == "POST http://127.0.0.1");
|
||||
Assert.Contains(rows, d => d.Data == secretPath + "?unmarked=1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(WebhookNotificationService))]
|
||||
[InlineData(PushHttpClientRegistration.ClientName)]
|
||||
public void Both_credential_clients_carry_the_mark(string clientName)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<ResiliencePolicies>();
|
||||
services.AddWebhookNotificationHttpClient();
|
||||
services.AddPushSender();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var handlers = new List<HttpMessageHandler>();
|
||||
for (HttpMessageHandler? handler = provider.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler(clientName);
|
||||
handler != null;
|
||||
handler = (handler as DelegatingHandler)?.InnerHandler)
|
||||
{
|
||||
handlers.Add(handler);
|
||||
}
|
||||
|
||||
Assert.Single(handlers.OfType<CredentialUrlRedactionProcessor.MarkingHandler>());
|
||||
}
|
||||
|
||||
/// <summary>Answers each of <paramref name="connections"/> requests with 204 and closes the connection.</summary>
|
||||
private static async Task AnswerNoContentAsync(TcpListener listener, int connections)
|
||||
{
|
||||
for (var i = 0; i < connections; i++)
|
||||
{
|
||||
using var socket = await listener.AcceptTcpClientAsync();
|
||||
var stream = socket.GetStream();
|
||||
var buffer = new byte[8192];
|
||||
var received = new StringBuilder();
|
||||
while (!received.ToString().Contains("\r\n\r\n", StringComparison.Ordinal))
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer);
|
||||
if (read == 0) break;
|
||||
received.Append(Encoding.ASCII.GetString(buffer, 0, read));
|
||||
}
|
||||
// The request body ("{}") follows the headers; the 204 does not wait for it.
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,4 +640,116 @@ public class DistressValidationTests
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), ReportDistressFunction.MaxTimestampFuture);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ResolveErrorType — the agent reports every backend 403 as DeviceNotRegistered; the
|
||||
// hardware whitelist runs before the registration lookup, so a 403 for hardware outside
|
||||
// the whitelist is stored as HardwareNotAllowed.
|
||||
// =========================================================================
|
||||
|
||||
private const string ResolveTenant = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
private static TenantConfiguration ValidatingTenant(Action<TenantConfiguration>? change = null)
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(ResolveTenant);
|
||||
config.ValidateAutopilotDevice = true; // CreateDefault starts with every validation off
|
||||
change?.Invoke(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static DistressErrorType Resolve(
|
||||
TenantConfiguration config, string? manufacturer = "VMware, Inc.", string? model = "VMware20,1",
|
||||
DistressErrorType reported = DistressErrorType.DeviceNotRegistered, int? http = 403,
|
||||
DistressCertSourceState? certState = null)
|
||||
=> ReportDistressFunction.ResolveErrorType(reported, http, manufacturer, model, certState, config);
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ManufacturerOutsideTheWhitelist_IsHardwareNotAllowed()
|
||||
{
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(ValidatingTenant()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ModelOutsideTheWhitelist_IsHardwareNotAllowed()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ModelWhitelist = "Latitude*");
|
||||
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config, "Dell Inc.", "OptiPlex 7090"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell Inc.", "Latitude 5440")]
|
||||
[InlineData("LENOVO", "21HD")] // wildcards match case-insensitively
|
||||
[InlineData("HP", "EliteBook 840 G10")]
|
||||
[InlineData("Microsoft Corporation", "Surface Laptop 5")]
|
||||
public void ResolveErrorType_WhitelistedHardware_StaysDeviceNotRegistered(string manufacturer, string model)
|
||||
{
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ManufacturerAddedToTheWhitelist_StaysDeviceNotRegistered()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ManufacturerWhitelist += ",VMware*");
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "VMware20,1")]
|
||||
[InlineData("VMware, Inc.", null)]
|
||||
[InlineData("", "")]
|
||||
public void ResolveErrorType_WithoutManufacturerOrModel_StaysAsReported(string? manufacturer, string? model)
|
||||
{
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_SuspendedTenant_StaysAsReported()
|
||||
{
|
||||
// The validator refuses a suspended tenant before it looks at the hardware.
|
||||
var config = ValidatingTenant(c => c.Disabled = true);
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_TenantWithoutValidation_StaysAsReported()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ValidateAutopilotDevice = false);
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_TestTenantWithoutValidationButInsecureRequests_IsHardwareNotAllowed()
|
||||
{
|
||||
var config = ValidatingTenant(c =>
|
||||
{
|
||||
c.ValidateAutopilotDevice = false;
|
||||
c.AllowInsecureAgentRequests = true;
|
||||
});
|
||||
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_NoCertificateInStore_StaysAsReported()
|
||||
{
|
||||
// Without a certificate the agent authenticates with a bootstrap token, which skips the
|
||||
// hardware check.
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered,
|
||||
Resolve(ValidatingTenant(), certState: DistressCertSourceState.NoCertInStore));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DistressErrorType.AuthCertificateRejected, 401)]
|
||||
[InlineData(DistressErrorType.TpmPssUnsupported, 403)]
|
||||
[InlineData(DistressErrorType.ClientCertificateNotReceived, 403)]
|
||||
[InlineData(DistressErrorType.DeviceNotRegistered, 401)]
|
||||
[InlineData(DistressErrorType.DeviceNotRegistered, null)]
|
||||
public void ResolveErrorType_OtherTypesAndStatusCodes_StayAsReported(DistressErrorType reported, int? http)
|
||||
{
|
||||
Assert.Equal(reported, Resolve(ValidatingTenant(), reported: reported, http: http));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ public class GetHardwareRejectedFunctionTests
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("HardwareNotAllowed", "Dell", "Latitude", "SN1", T0),
|
||||
MakeEntry("CertificateExpired", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("Timeout", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
MakeEntry("DeviceNotRegistered", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
};
|
||||
|
||||
var (aggregated, totalRawReports) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
@@ -64,6 +64,11 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
"graphAccessRejected",
|
||||
"intuneDeviceBindingPermissionMissing",
|
||||
"isTrial",
|
||||
// Non-sensitive by review: booleans only; who hid the guide and when stays in the
|
||||
// admin-gated config. hasEnrollmentSessions is absent here: unknown → omitted.
|
||||
"setupGuideDismissed",
|
||||
"setupGuideHardwareDone",
|
||||
"setupGuideSettingsDone",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
|
||||
@@ -520,4 +525,53 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
|
||||
Assert.DoesNotContain("Secret Holdings", json);
|
||||
}
|
||||
|
||||
// ── setup guide ──
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_NewTenantHasEveryStepOpen()
|
||||
{
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(TenantConfiguration.CreateDefault("t"), Now, hasEnrollmentSessions: false);
|
||||
|
||||
Assert.False(payload.SetupGuideSettingsDone);
|
||||
Assert.False(payload.SetupGuideHardwareDone);
|
||||
Assert.False(payload.SetupGuideDismissed);
|
||||
Assert.False(payload.HasEnrollmentSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_ReflectsMarkersAndDismissal()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideSettingsReviewedAt = Now;
|
||||
config.SetupGuideHardwareReviewedAt = Now;
|
||||
config.SetupGuideDismissedAt = Now;
|
||||
config.SetupGuideDismissedBy = "admin@contoso.com";
|
||||
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
||||
|
||||
Assert.True(payload.SetupGuideSettingsDone);
|
||||
Assert.True(payload.SetupGuideHardwareDone);
|
||||
Assert.True(payload.SetupGuideDismissed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_UnknownEnrollmentHistoryIsOmitted()
|
||||
{
|
||||
var element = Serialize(TenantConfiguration.CreateDefault("t"));
|
||||
|
||||
Assert.False(element.TryGetProperty("hasEnrollmentSessions", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_NeverLeaksWhoDismissedIt()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideDismissedAt = Now;
|
||||
config.SetupGuideDismissedBy = "hidden.admin@contoso.com";
|
||||
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("hidden.admin", json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ public class InfrastructureWireParityTests
|
||||
mcpClientRegistrationEnabled = true,
|
||||
whatsNewSeenPlatformUtc = platformSeen,
|
||||
whatsNewSeenAgentUtc = agentSeen,
|
||||
dpaAcceptancePending = true
|
||||
dpaAcceptancePending = true,
|
||||
companyName = "Contoso Ltd."
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -74,7 +75,8 @@ public class InfrastructureWireParityTests
|
||||
McpClientRegistrationEnabled = true,
|
||||
WhatsNewSeenPlatformUtc = platformSeen,
|
||||
WhatsNewSeenAgentUtc = agentSeen,
|
||||
DpaAcceptancePending = true
|
||||
DpaAcceptancePending = true,
|
||||
CompanyName = "Contoso Ltd."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> is the one definition of the allowed-list entry
|
||||
/// grammar; the agent tests run its parse cases against the netstandard2.0 build, the portal runs the file
|
||||
/// against <c>allowedAccounts.ts</c>. Plus the list-level write rules of
|
||||
/// <see cref="TenantConfigValidation.ValidateLocalAdminAllowedAccounts"/>.
|
||||
/// </summary>
|
||||
public class LocalAdminAllowListParityTests
|
||||
{
|
||||
private static readonly Lazy<JObject> Fixture = new(() =>
|
||||
JObject.Parse(File.ReadAllText(Path.Combine(FindRepoRoot(), "tests", "fixtures", "local-admin-allow-list", "cases.json"))));
|
||||
|
||||
public static IEnumerable<object[]> ParseEntries() => Fixture.Value["parse"]!.Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
public static IEnumerable<object[]> WriteEntries() => Fixture.Value["write"]!.Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParseEntries))]
|
||||
public void Parse_matches_the_shared_case(string entry)
|
||||
{
|
||||
var c = Fixture.Value["parse"]!.First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
|
||||
|
||||
if (c.Value<bool?>("ignored") == true)
|
||||
{
|
||||
Assert.False(parsed);
|
||||
return;
|
||||
}
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(c.Value<string>("pattern"), pattern);
|
||||
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(WriteEntries))]
|
||||
public void New_entry_validation_matches_the_shared_case(string entry)
|
||||
{
|
||||
var c = Fixture.Value["write"]!.First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var error = LocalAdminAllowList.ValidateNewEntry(entry);
|
||||
|
||||
if (c.Value<bool>("valid"))
|
||||
Assert.Null(error);
|
||||
else
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_cases_are_present()
|
||||
{
|
||||
Assert.True(Fixture.Value["parse"]!.Count() >= 15, "cases.json lost parse cases.");
|
||||
Assert.True(Fixture.Value["write"]!.Count() >= 25, "cases.json lost write cases.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builtin_catalog_keeps_the_four_rid_accounts_and_the_standard_user_system_accounts()
|
||||
{
|
||||
var rid = LocalAdminAllowList.BuiltIns.Where(b => b.Match == LocalAdminBuiltInMatch.Rid).ToDictionary(b => b.Rid!.Value, b => b.Role);
|
||||
Assert.Equal(LocalAccountRole.Any, rid[500]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[501]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[503]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[504]);
|
||||
|
||||
string RoleOf(string name) => LocalAdminAllowList.BuiltIns
|
||||
.Single(b => b.Match == LocalAdminBuiltInMatch.AccountName && b.Name == name).Role.ToString();
|
||||
Assert.Equal("StandardUser", RoleOf("WsiAccount"));
|
||||
Assert.Equal("StandardUser", RoleOf("kioskUser0"));
|
||||
Assert.Equal("StandardUser", RoleOf("Administrator"));
|
||||
Assert.Equal("Any", RoleOf("defaultuser0"));
|
||||
}
|
||||
|
||||
// ── list-level write rules ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "[\"adm-*\",\"kiosk|user\"]")]
|
||||
[InlineData("[\"adm-*\"]", "[\"adm-*\",\"kiosk|user\"]")]
|
||||
[InlineData("[\"adm-*\"]", "[]")]
|
||||
[InlineData("[\"adm-*\"]", "")]
|
||||
[InlineData("[\"*\"]", "[\"*\"]")]
|
||||
public void Valid_or_unchanged_lists_pass(string? stored, string candidate)
|
||||
{
|
||||
Assert.Null(TenantConfigValidation.ValidateLocalAdminAllowedAccounts(candidate, stored));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stored_entry_is_never_rechecked_when_another_entry_is_added()
|
||||
{
|
||||
// Lists written before the grammar existed (a bare "*", an Entra SID) stay saveable.
|
||||
Assert.Null(TenantConfigValidation.ValidateLocalAdminAllowedAccounts(
|
||||
"[\"*\",\"S-1-12-1-1-2-3-4\",\"WsiAccount\",\"kiosk|user\"]",
|
||||
"[\"*\",\"S-1-12-1-1-2-3-4\",\"WsiAccount\"]"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"a\":1}", "JSON array")]
|
||||
[InlineData("[1,2]", "JSON array")]
|
||||
[InlineData("not json", "JSON array")]
|
||||
[InlineData("[\"*\"]", "too broad")]
|
||||
[InlineData("[\"kiosk|admin\"]", "is not valid")]
|
||||
[InlineData("[\"kiosk\",\"kiosk|user\"]", "listed twice")]
|
||||
[InlineData("[\"Kiosk\",\"kiosk\"]", "listed twice")]
|
||||
public void Invalid_new_entries_are_rejected(string candidate, string expected)
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts(candidate, null);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expected, error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_entry_repeating_a_stored_pattern_is_rejected()
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts("[\"WsiAccount\",\"WsiAccount|user\"]", "[\"WsiAccount\"]");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("listed twice", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_entry_count_is_capped()
|
||||
{
|
||||
var entries = Enumerable.Range(0, LocalAdminAllowList.MaxEntries + 1).Select(i => $"\"svc{i:D3}\"");
|
||||
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts("[" + string.Join(",", entries) + "]", null);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains($"at most {LocalAdminAllowList.MaxEntries}", error);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir != null && !File.Exists(Path.Combine(dir, "AutopilotMonitor.sln")))
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
return dir ?? throw new DirectoryNotFoundException("Could not locate repo root (AutopilotMonitor.sln).");
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,7 @@ public class PerPartitionFanOutMergeTests
|
||||
[InlineData(5)]
|
||||
[InlineData(15)]
|
||||
[InlineData(100)]
|
||||
[InlineData(1000)]
|
||||
public async Task Walk_returns_every_row_once_in_global_order_and_never_an_empty_page(int pageSize)
|
||||
{
|
||||
// Uneven partitions: one empty, one dominant, interleaved timestamps and a tie.
|
||||
|
||||
@@ -158,6 +158,7 @@ internal sealed class InMemoryPushDeviceRepository : IPushDeviceRepository
|
||||
case "P256dh": d.P256dh = (string)value!; break;
|
||||
case "Auth": d.Auth = (string)value!; break;
|
||||
case "VapidKid": d.VapidKid = (string)value!; break;
|
||||
case "Label": d.Label = (string)value!; break;
|
||||
default: throw new InvalidOperationException($"unexpected patch key {key}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@ using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
@@ -18,7 +16,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the
|
||||
/// webhook path, one fan-out per send), the validation rules (no destination, open to tenant
|
||||
/// admins — only E-mail stays behind the GA gate — and one Push channel per scope),
|
||||
/// the raw-table deny-list and the dependency redaction.
|
||||
/// and the raw-table deny-list. The dependency redaction lives in CredentialUrlRedactionProcessorTests.
|
||||
/// </summary>
|
||||
public class PushChannelRoutingTests
|
||||
{
|
||||
@@ -182,30 +180,4 @@ public class PushChannelRoutingTests
|
||||
Assert.Contains(Constants.TableNames.PushDevices, Constants.TableNames.CredentialBearing);
|
||||
Assert.Contains(Constants.TableNames.PushPairingGrants, Constants.TableNames.CredentialBearing);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
|
||||
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
|
||||
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
|
||||
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
|
||||
public void Dependency_rows_for_push_services_keep_scheme_and_host_only(string endpoint, string expected)
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = endpoint, Name = "POST " + endpoint, Target = new Uri(endpoint).Host };
|
||||
|
||||
PushDependencyRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal(expected, dependency.Data);
|
||||
Assert.Equal("POST " + expected, dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dependency_rows_for_other_hosts_are_untouched()
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = "https://graph.microsoft.com/v1.0/devices/abc", Name = "GET /v1.0/devices/abc" };
|
||||
|
||||
PushDependencyRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices/abc", dependency.Data);
|
||||
Assert.Equal("GET /v1.0/devices/abc", dependency.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,43 @@ public class PushDeliveryServiceTests
|
||||
Assert.Equal((int)status, row.LastStatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Start_alert_goes_out_at_high_urgency(bool isResume)
|
||||
{
|
||||
// D-424: an Info start below "high" waits at FCM while the phone dozes, and the verdict sharing
|
||||
// its session Topic then replaces it — the start never reaches the phone.
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
|
||||
"DESKTOP-4711", "SN-0001", "Dell Inc.", "Latitude 7450", isResume, Constants.PortalSessionUrl("session-1"));
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, alert);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal("high", Assert.Single(request.Headers.GetValues("Urgency")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationSeverity.Error, "high")]
|
||||
[InlineData(NotificationSeverity.Warning, "high")]
|
||||
[InlineData(NotificationSeverity.Success, "normal")]
|
||||
[InlineData(NotificationSeverity.Info, "low")]
|
||||
public async Task Other_alerts_take_their_urgency_from_the_severity(NotificationSeverity severity, string expected)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
var alert = Alert();
|
||||
alert.EventType = "whats_new";
|
||||
alert.Severity = severity;
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, alert);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal(expected, Assert.Single(request.Headers.GetValues("Urgency")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flood_window_mutes_after_the_cap_with_one_summary_push()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// <summary>
|
||||
/// The 403 a refused pairing answers with: neutral, naming the role the scope needs — the member
|
||||
/// list of the tenant on the tenant routes, the Global Administrator role on the platform routes.
|
||||
/// Plus the label bodies of the portal routes: optional on confirm, required on rename.
|
||||
/// </summary>
|
||||
public class PushPairingFunctionsTests
|
||||
{
|
||||
@@ -39,6 +40,59 @@ public class PushPairingFunctionsTests
|
||||
await AssertForbiddenAsync(response, "Push notifications on the platform need the Global Administrator role.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "iPhone")] // a portal without the name field sends no body
|
||||
[InlineData("{\"label\":\"\"}", "iPhone")]
|
||||
[InlineData("{\"label\":\"iPhone Otto\"}", "iPhone Otto")]
|
||||
public async Task Confirm_reads_an_optional_name(string? body, string expectedLabel)
|
||||
{
|
||||
var h = new PushPairingServiceTests.Harness();
|
||||
var sut = new PushPairingFunctions(h.Sut, NullLogger<PushPairingFunctions>.Instance);
|
||||
var pairingId = await RedeemedPairingAsync(h, sut);
|
||||
|
||||
var response = await sut.Confirm(EndpointHarness.Request(TenantId, jsonBody: body).Req, pairingId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var device = h.Repo.Devices.Values.Single();
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
|
||||
Assert.Equal(expectedLabel, device.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_answers_204_and_maps_a_blank_name_to_400_and_an_unknown_device_to_404()
|
||||
{
|
||||
var h = new PushPairingServiceTests.Harness();
|
||||
var pairing = new PushPairingFunctions(h.Sut, NullLogger<PushPairingFunctions>.Instance);
|
||||
var pairingId = await RedeemedPairingAsync(h, pairing);
|
||||
await pairing.Confirm(EndpointHarness.Request(TenantId).Req, pairingId);
|
||||
var deviceId = h.Repo.Devices.Values.Single().DeviceId;
|
||||
var sut = new PushDevicesFunctions(h.Sut, NullLogger<PushDevicesFunctions>.Instance);
|
||||
|
||||
var renamed = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\"iPhone IT\"}").Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.NoContent, renamed.StatusCode);
|
||||
Assert.Equal("iPhone IT", h.Repo.Devices.Values.Single().Label);
|
||||
|
||||
var blank = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\" \"}").Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, blank.StatusCode);
|
||||
var noBody = await sut.Rename(EndpointHarness.Request(TenantId).Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, noBody.StatusCode);
|
||||
Assert.Equal("iPhone IT", h.Repo.Devices.Values.Single().Label);
|
||||
|
||||
var unknown = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\"x\"}").Req, "unknownDevice");
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>A pairing created through the tenant route by the harness caller and redeemed by a device named "iPhone".</summary>
|
||||
private static async Task<string> RedeemedPairingAsync(PushPairingServiceTests.Harness h, PushPairingFunctions sut)
|
||||
{
|
||||
var created = await sut.Create(EndpointHarness.Request(TenantId).Req);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
created.Body.Position = 0;
|
||||
var pairing = (await JsonSerializer.DeserializeAsync<CreatePairingResponse>(created.Body, ApiJsonOptions.Read))!;
|
||||
Assert.True((await h.Sut.RedeemAsync(h.RedeemRequest(pairing.Code, label: "iPhone"))).Ok);
|
||||
return pairing.PairingId;
|
||||
}
|
||||
|
||||
private static PushPairingFunctions RefusingFunctions()
|
||||
{
|
||||
var pairing = new PushPairingServiceTests.Harness();
|
||||
|
||||
@@ -400,6 +400,54 @@ public class PushPairingServiceTests
|
||||
Assert.Empty(h.Repo.Devices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_takes_the_name_chosen_on_the_computer_and_a_blank_one_keeps_the_devices()
|
||||
{
|
||||
var h = new Harness();
|
||||
var renamed = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(renamed.Value!.Code, label: "iPhone"));
|
||||
Assert.True((await h.Sut.ConfirmAsync(Tenant, renamed.Value.PairingId, Admin, " iPhone \u0007IT ")).Ok);
|
||||
var device = h.Repo.Devices.Values.Single();
|
||||
Assert.Equal("iPhone IT", device.Label);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(TenantId, "CREATE", "PushDevice", device.DeviceId, Admin.Upn,
|
||||
It.Is<Dictionary<string, string>>(d => d["label"] == "iPhone IT" && !d.ContainsKey("previousLabel"))), Times.Once);
|
||||
|
||||
var kept = await h.Sut.CreateAsync(Tenant, Operator);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(kept.Value!.Code, label: "ops phone"));
|
||||
Assert.True((await h.Sut.ConfirmAsync(Tenant, kept.Value.PairingId, Operator, " \u0007 ")).Ok);
|
||||
Assert.Equal("ops phone", h.Repo.Devices.Values.Single(d => d.OwnerUpn == Operator.Upn).Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_follows_the_remove_rule_cleans_the_name_and_audits_only_a_change()
|
||||
{
|
||||
var h = new Harness();
|
||||
var adminDevice = await PairAsync(h, Admin);
|
||||
var opsDevice = await PairAsync(h, Operator);
|
||||
|
||||
// An Operator renames only their own device; someone else's answers like a missing one.
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.RenameAsync(Tenant, adminDevice, "taken over", Operator));
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, "Ops phone", Operator));
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.RenameAsync(Tenant, "missing", "x", Admin));
|
||||
|
||||
// A scope admin renames every device of the scope.
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, " iPhone\u0007 Otto " + new string('z', 60), Admin));
|
||||
var renamed = h.Repo.Devices[(TenantId, opsDevice)];
|
||||
Assert.StartsWith("iPhone Otto zz", renamed.Label);
|
||||
Assert.Equal(Constants.Push.MaxLabelLength, renamed.Label.Length);
|
||||
Assert.Equal(renamed.Label, (await h.Sut.ListAsync(Tenant, Admin)).Devices.Single(d => d.DeviceId == opsDevice).Label);
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "PushDevice", opsDevice, Admin.Upn,
|
||||
It.Is<Dictionary<string, string>>(d => d["label"] == renamed.Label && d["previousLabel"] == "Ops phone")), Times.Once);
|
||||
|
||||
// Blank after cleaning is refused; the same name again writes nothing and audits nothing.
|
||||
Assert.Equal(PushOpError.InvalidLabel, await h.Sut.RenameAsync(Tenant, opsDevice, " \u0007 ", Admin));
|
||||
Assert.Equal(PushOpError.InvalidLabel, await h.Sut.RenameAsync(Tenant, opsDevice, null, Admin));
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, renamed.Label, Admin));
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(It.IsAny<string>(), "UPDATE", It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
/// <summary>Create → redeem → confirm: one Active device of <paramref name="owner"/>; returns its id.</summary>
|
||||
private static async Task<string> PairAsync(Harness h, PushCaller owner)
|
||||
{
|
||||
@@ -467,6 +515,8 @@ public class PushPairingServiceTests
|
||||
public VapidKeyRing Keys { get; }
|
||||
public Mock<PushEligibility> Eligibility { get; }
|
||||
public Mock<OpsEventService> OpsEvents { get; }
|
||||
/// <summary>The tenant audit log the pairing service writes to.</summary>
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public RecordingHandler Http { get; } = new();
|
||||
public PushPairingService Sut { get; }
|
||||
public PushDeliveryService Delivery { get; }
|
||||
@@ -499,7 +549,7 @@ public class PushPairingServiceTests
|
||||
.ReturnsAsync((new AutopilotMonitor.Shared.Models.TenantConfiguration { TenantId = TenantId, CompanyName = "Contoso" }, true));
|
||||
|
||||
Sut = new PushPairingService(Repo, Eligibility.Object, settings, Delivery, OpsEvents.Object,
|
||||
Mock.Of<IMaintenanceRepository>(), tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
|
||||
Maintenance.Object, tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
|
||||
|
||||
// A browser-shaped subscription: a fresh P-256 point + 16-byte auth secret.
|
||||
using var ua = System.Security.Cryptography.ECDiffieHellman.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the Web Push surface of the route policy catalog: the tier and tenant scoping of every push
|
||||
/// route, that the surface is exactly these 22 routes (a 23rd cannot appear unnoticed), that only the
|
||||
/// route, that the surface is exactly these 24 routes (a 25th cannot appear unnoticed), that only the
|
||||
/// five receiver routes skip JWT validation (the phone never signs in), that no push route carries a
|
||||
/// grant-widening opt-in, and that every push path is stamped <c>Cache-Control: no-store</c>.
|
||||
/// </summary>
|
||||
@@ -14,7 +14,7 @@ public class PushRoutePolicyTests
|
||||
{
|
||||
private const string SampleId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
/// <summary>The 22 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
|
||||
/// <summary>The 24 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
|
||||
private static readonly string[] PinnedRoutes =
|
||||
{
|
||||
// Receiver (the phone): one-shot pairing code, then the device token in X-Push-Device-Token.
|
||||
@@ -29,6 +29,7 @@ public class PushRoutePolicyTests
|
||||
"POST push/pairings/{pairingId}/confirm",
|
||||
"POST push/pairings/{pairingId}/reject",
|
||||
"GET push/devices",
|
||||
"PATCH push/devices/{deviceId}",
|
||||
"DELETE push/devices/{deviceId}",
|
||||
"POST push/devices/{deviceId}/test",
|
||||
// Session watches: ?tenantId= like the annotations route.
|
||||
@@ -41,6 +42,7 @@ public class PushRoutePolicyTests
|
||||
"POST global/push/pairings/{pairingId}/confirm",
|
||||
"POST global/push/pairings/{pairingId}/reject",
|
||||
"GET global/push/devices",
|
||||
"PATCH global/push/devices/{deviceId}",
|
||||
"DELETE global/push/devices/{deviceId}",
|
||||
"POST global/push/devices/{deviceId}/test",
|
||||
};
|
||||
@@ -60,6 +62,7 @@ public class PushRoutePolicyTests
|
||||
[InlineData("POST", "push/pairings/{pairingId}/confirm", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/pairings/{pairingId}/reject", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("GET", "push/devices", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("PATCH", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("DELETE", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/devices/{deviceId}/test", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
// Session watches: ?tenantId= (optional, falls back to the JWT tenant; middleware cross-tenant check).
|
||||
@@ -72,6 +75,7 @@ public class PushRoutePolicyTests
|
||||
[InlineData("POST", "global/push/pairings/{pairingId}/confirm", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/pairings/{pairingId}/reject", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("GET", "global/push/devices", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("PATCH", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("DELETE", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/devices/{deviceId}/test", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
public void Push_route_resolves_to_its_tier_and_tenant_scoping(string method, string template, EndpointPolicy policy, TenantScoping scoping)
|
||||
@@ -85,7 +89,7 @@ public class PushRoutePolicyTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_push_surface_is_exactly_the_22_pinned_routes()
|
||||
public void The_push_surface_is_exactly_the_24_pinned_routes()
|
||||
{
|
||||
var expected = PinnedRoutes.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
var actual = PushSurface().Select(Key).OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The setup guide's markers (POST config/{tenantId}/setup-guide) and the done rules the feature
|
||||
/// flags derive from the row.
|
||||
/// </summary>
|
||||
public class SetupGuideFunctionTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string Admin = "admin@contoso.com";
|
||||
|
||||
// ── step parsing ──
|
||||
|
||||
[Theory]
|
||||
[InlineData("settings", "settings")]
|
||||
[InlineData(" Hardware ", "hardware")]
|
||||
[InlineData("DISMISS", "dismiss")]
|
||||
[InlineData("restore", "restore")]
|
||||
[InlineData("validation", null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void NormalizeStep_accepts_only_the_four_steps(string? input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, SetupGuideFunction.NormalizeStep(input));
|
||||
}
|
||||
|
||||
// ── ApplyStep ──
|
||||
|
||||
[Fact]
|
||||
public void Settings_step_is_stamped_once()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "settings", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideSettingsReviewedAt);
|
||||
Assert.Equal(Admin, row.UpdatedBy);
|
||||
|
||||
// A second completion keeps the first one on record and writes nothing.
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "settings", "other@contoso.com", Now.AddHours(1)));
|
||||
Assert.Equal(Now, row.SetupGuideSettingsReviewedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_step_is_stamped_once()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "hardware", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideHardwareReviewedAt);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "hardware", Admin, Now.AddMinutes(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dismiss_records_who_and_restore_clears_it()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "dismiss", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideDismissedAt);
|
||||
Assert.Equal(Admin, row.SetupGuideDismissedBy);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "dismiss", Admin, Now.AddMinutes(1)));
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "restore", Admin, Now.AddMinutes(2)));
|
||||
Assert.Null(row.SetupGuideDismissedAt);
|
||||
Assert.Null(row.SetupGuideDismissedBy);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "restore", Admin, Now.AddMinutes(3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_step_writes_nothing()
|
||||
{
|
||||
var row = new TenantConfiguration { UpdatedBy = "earlier" };
|
||||
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "validation", Admin, Now));
|
||||
Assert.Equal("earlier", row.UpdatedBy);
|
||||
}
|
||||
|
||||
// ── SetupGuideState.SettingsDone ──
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_open_on_a_new_tenant()
|
||||
{
|
||||
Assert.False(SetupGuideState.SettingsDone(TenantConfiguration.CreateDefault("t")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_done_once_reviewed_even_if_nothing_was_applied()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideSettingsReviewedAt = Now;
|
||||
|
||||
Assert.True(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_done_when_all_three_are_already_on()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.EnableTimezoneAutoSet = true;
|
||||
config.EnableSoftwareInventoryAnalyzer = true;
|
||||
config.DiagnosticsUploadDestination = "Hosted";
|
||||
config.DiagnosticsUploadMode = "OnFailure";
|
||||
|
||||
Assert.True(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_stay_open_while_one_of_the_three_is_off()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.EnableTimezoneAutoSet = true;
|
||||
config.EnableSoftwareInventoryAnalyzer = true;
|
||||
config.DiagnosticsUploadDestination = "Hosted";
|
||||
config.DiagnosticsUploadMode = "Off";
|
||||
|
||||
Assert.False(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
// ── SetupGuideState.HardwareDone ──
|
||||
|
||||
[Fact]
|
||||
public void Hardware_is_open_with_the_default_whitelist()
|
||||
{
|
||||
Assert.False(SetupGuideState.HardwareDone(TenantConfiguration.CreateDefault("t")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_is_done_once_reviewed()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideHardwareReviewedAt = Now;
|
||||
|
||||
Assert.True(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation,VMware*", "*")]
|
||||
[InlineData("*", "*")]
|
||||
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation", "Latitude*")]
|
||||
public void Hardware_is_done_when_the_whitelist_differs_from_the_default(string manufacturers, string models)
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.ManufacturerWhitelist = manufacturers;
|
||||
config.ModelWhitelist = models;
|
||||
|
||||
Assert.True(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_ignores_order_case_and_spacing_of_the_default_entries()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.ManufacturerWhitelist = "microsoft corporation, lenovo*,HP*, dell*";
|
||||
|
||||
Assert.False(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,30 @@ public sealed class SharedManifestParityTests
|
||||
// Limits of feedback and reports (text lengths, the report body cap, the daily feedback cap):
|
||||
// the portal's counters and upload budget read the same numbers the backend enforces.
|
||||
["submissionLimits"] = ConstInts(typeof(Constants.SubmissionLimits)),
|
||||
// Local Admin Analyzer allowed list: the built-in entries and the entry grammar. The portal's
|
||||
// Allowed Local Accounts card renders and checks against these; agent and backend use the class.
|
||||
["localAdminAllowList"] = new Dictionary<string, object?>
|
||||
{
|
||||
["roleSeparator"] = LocalAdminAllowList.RoleSeparator.ToString(),
|
||||
["standardUserQualifier"] = LocalAdminAllowList.StandardUserQualifier,
|
||||
["maxEntries"] = LocalAdminAllowList.MaxEntries,
|
||||
["maxEntryLength"] = LocalAdminAllowList.MaxEntryLength,
|
||||
["minPatternLiterals"] = LocalAdminAllowList.MinPatternLiterals,
|
||||
["forbiddenNameCharacters"] = LocalAdminAllowList.ForbiddenNameCharacters,
|
||||
["builtIns"] = LocalAdminAllowList.BuiltIns.Select(b => new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = b.Name,
|
||||
["match"] = b.Match switch
|
||||
{
|
||||
LocalAdminBuiltInMatch.Rid => "rid",
|
||||
LocalAdminBuiltInMatch.AccountName => "account",
|
||||
LocalAdminBuiltInMatch.ProfileFolder => "folder",
|
||||
_ => throw new InvalidOperationException($"Unmapped built-in match {b.Match}"),
|
||||
},
|
||||
["role"] = b.Role == LocalAccountRole.StandardUser ? "user" : "any",
|
||||
["rid"] = b.Rid,
|
||||
}).ToArray(),
|
||||
},
|
||||
// Every IApiResponse + IApiRequest implementer + [WireContract] type, transitively closed —
|
||||
// the source of lib/generated/wire-types.generated.ts. See WireTypeManifestBuilder.
|
||||
["types"] = WireTypeManifestBuilder.BuildTypesSection(),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Azure Tables refuses <c>$top</c> above 1000 with 400 InvalidInput. A computed page size
|
||||
/// handed to <c>maxPerPage</c> unguarded reaches that limit only at the edge (a fan-out reader
|
||||
/// that asked for <c>pageSize + 1</c> answered every <c>pageSize=1000</c> request with an empty
|
||||
/// page). Ratchet: every <c>maxPerPage</c> argument is a literal of at most 1000 or goes through
|
||||
/// <see cref="AzureTablesPaginator.ServerPageSize"/> / <c>Math.Min(…, 1000)</c>.
|
||||
/// </summary>
|
||||
public class TableServerPageSizeGuardTests
|
||||
{
|
||||
private static readonly Regex AllowedForm = new(
|
||||
@"^(\d+|(AzureTablesPaginator\.)?ServerPageSize\(.+\)|Math\.Min\(.+,\s*1000\s*\))$",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline);
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(16, 16)]
|
||||
[InlineData(1000, 1000)]
|
||||
[InlineData(1001, 1000)]
|
||||
[InlineData(int.MaxValue, 1000)]
|
||||
public void ServerPageSize_never_exceeds_the_Azure_limit(int rows, int expected)
|
||||
{
|
||||
Assert.Equal(expected, AzureTablesPaginator.ServerPageSize(rows));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_maxPerPage_argument_stays_within_the_Azure_limit()
|
||||
{
|
||||
var root = FunctionsRoot();
|
||||
var offenders = new List<string>();
|
||||
var found = 0;
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var rel = Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||
if (rel.StartsWith("bin/", StringComparison.Ordinal) || rel.StartsWith("obj/", StringComparison.Ordinal)) continue;
|
||||
|
||||
var source = File.ReadAllText(path);
|
||||
foreach (var arg in MaxPerPageArguments(source))
|
||||
{
|
||||
found++;
|
||||
if (!IsAllowed(arg)) offenders.Add($"{rel}: maxPerPage: {arg}");
|
||||
}
|
||||
}
|
||||
|
||||
// Positive control: the scan must see the known call sites, or it proves nothing.
|
||||
Assert.True(found > 20, $"Only {found} maxPerPage arguments found — the scan lost its call sites.");
|
||||
Assert.True(offenders.Count == 0,
|
||||
"maxPerPage beyond Azure's $top limit is possible — pass AzureTablesPaginator.ServerPageSize(n) " +
|
||||
"or a literal of at most 1000:\n " + string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1000", true)]
|
||||
[InlineData("25", true)]
|
||||
[InlineData("1001", false)]
|
||||
[InlineData("limit", false)]
|
||||
[InlineData("pageSize + 1", false)]
|
||||
[InlineData("AzureTablesPaginator.ServerPageSize(limit)", true)]
|
||||
[InlineData("Math.Min(maxResults + 1, 1000)", true)]
|
||||
[InlineData("Math.Min(maxResults, 5000)", false)]
|
||||
public void IsAllowed_accepts_only_bounded_forms(string argument, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, IsAllowed(argument));
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string argument)
|
||||
{
|
||||
if (!AllowedForm.IsMatch(argument)) return false;
|
||||
return !int.TryParse(argument, out var literal) || literal <= AzureTablesPaginator.MaxServerPageSize;
|
||||
}
|
||||
|
||||
/// <summary>The argument text after each <c>maxPerPage:</c>, up to the comma or parenthesis that ends it.</summary>
|
||||
private static IEnumerable<string> MaxPerPageArguments(string source)
|
||||
{
|
||||
const string marker = "maxPerPage:";
|
||||
var at = 0;
|
||||
while ((at = source.IndexOf(marker, at, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var i = at + marker.Length;
|
||||
at = i;
|
||||
// A mention inside a comment ("(maxPerPage: 1)") is not a call site.
|
||||
var lineStart = source.LastIndexOf('\n', i) + 1;
|
||||
if (source.AsSpan(lineStart, i - lineStart).TrimStart().StartsWith("//")) continue;
|
||||
|
||||
var depth = 0;
|
||||
var start = i;
|
||||
for (; i < source.Length; i++)
|
||||
{
|
||||
var c = source[i];
|
||||
if (c == '(') depth++;
|
||||
else if (c == ')') { if (depth == 0) break; depth--; }
|
||||
else if (c == ',' && depth == 0) break;
|
||||
}
|
||||
yield return source[start..i].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static string FunctionsRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,10 @@ public class TenantConfigPatchServiceTests
|
||||
[InlineData("intuneDeviceBindingPermissionMissingConfirmedAt")]
|
||||
[InlineData("cloudPcPermissionMissingSince")]
|
||||
[InlineData("cloudPcPermissionMissingConfirmedAt")]
|
||||
[InlineData("setupGuideSettingsReviewedAt")]
|
||||
[InlineData("setupGuideHardwareReviewedAt")]
|
||||
[InlineData("setupGuideDismissedAt")]
|
||||
[InlineData("setupGuideDismissedBy")]
|
||||
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
@@ -485,6 +489,36 @@ public class TenantConfigPatchServiceTests
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[\"*\"]")]
|
||||
[InlineData("[\"kiosk|admin\"]")]
|
||||
[InlineData("[\"S-1-12-1-1-2-3-4\"]")]
|
||||
[InlineData("\"kiosk\"")]
|
||||
public async Task Patch_InvalidAllowedLocalAccount_ValidationFails_NoWrite(string json)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("localAdminAllowedAccountsJson", json)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
Assert.Contains("allowed local accounts", outcome.Error);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_StandardUserAllowedLocalAccount_Applies()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("localAdminAllowedAccountsJson", "[\"adm-*\",\"kiosk|user\"]")), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success, outcome.Error);
|
||||
Assert.Equal("[\"adm-*\",\"kiosk|user\"]", harness.Current!.LocalAdminAllowedAccountsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_UnrestrictedModeWithoutGate_ExplicitError_NotSilentFlip()
|
||||
{
|
||||
|
||||
@@ -247,6 +247,34 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Null(mapped.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Null(mapped.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(mapped.CloudPcPermissionMissingConfirmedAt);
|
||||
Assert.Null(mapped.SetupGuideSettingsReviewedAt);
|
||||
Assert.Null(mapped.SetupGuideHardwareReviewedAt);
|
||||
Assert.Null(mapped.SetupGuideDismissedAt);
|
||||
Assert.Null(mapped.SetupGuideDismissedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_SetupGuideFields_SurviveStoreAndMap()
|
||||
{
|
||||
var at = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
SetupGuideSettingsReviewedAt = at,
|
||||
SetupGuideHardwareReviewedAt = at.AddMinutes(2),
|
||||
SetupGuideDismissedAt = at.AddDays(1),
|
||||
SetupGuideDismissedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.Equal(at, mapped.SetupGuideSettingsReviewedAt);
|
||||
Assert.Equal(at.AddMinutes(2), mapped.SetupGuideHardwareReviewedAt);
|
||||
Assert.Equal(at.AddDays(1), mapped.SetupGuideDismissedAt);
|
||||
Assert.Equal("admin@contoso.com", mapped.SetupGuideDismissedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -393,6 +393,10 @@ public class TypedContractCompletionWireParityTests
|
||||
trialAvailable = true,
|
||||
contactEmailSet = false,
|
||||
companyNameSet = false,
|
||||
setupGuideSettingsDone = true,
|
||||
setupGuideHardwareDone = false,
|
||||
setupGuideDismissed = false,
|
||||
hasEnrollmentSessions = false,
|
||||
entitlements = new
|
||||
{
|
||||
retentionCapDays = 90,
|
||||
@@ -424,6 +428,10 @@ public class TypedContractCompletionWireParityTests
|
||||
TrialAvailable = true,
|
||||
ContactEmailSet = false,
|
||||
CompanyNameSet = false,
|
||||
SetupGuideSettingsDone = true,
|
||||
SetupGuideHardwareDone = false,
|
||||
SetupGuideDismissed = false,
|
||||
HasEnrollmentSessions = false,
|
||||
Entitlements = new TenantFeatureEntitlements
|
||||
{
|
||||
RetentionCapDays = 90,
|
||||
|
||||
+5
@@ -34,6 +34,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// exactly when more than <c>pageSize</c> were read in total: if no partition reached its limit,
|
||||
/// every partition is exhausted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That limit is a row count, not an Azure page size: Azure Tables refuses <c>$top</c> above
|
||||
/// 1000, so a reader passes <c>AzureTablesPaginator.ServerPageSize(limit)</c> as
|
||||
/// <c>maxPerPage</c> and keeps reading across server pages until it has its limit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class PerPartitionFanOutMerge
|
||||
{
|
||||
|
||||
+9
@@ -42,6 +42,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
"GraphAccessRejectedSince", "GraphAccessRejectedCode", "GraphAccessRejectionConfirmedAt",
|
||||
"IntuneDeviceBindingPermissionMissingSince", "IntuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
"CloudPcPermissionMissingSince", "CloudPcPermissionMissingConfirmedAt",
|
||||
"SetupGuideSettingsReviewedAt", "SetupGuideHardwareReviewedAt", "SetupGuideDismissedAt", "SetupGuideDismissedBy",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AdminBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -743,6 +744,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "DpaAcceptancePending", config.DpaAcceptancePending },
|
||||
{ "DpaAcceptedBy", config.DpaAcceptedBy },
|
||||
{ "DpaAcceptedAt", config.DpaAcceptedAt },
|
||||
{ "SetupGuideSettingsReviewedAt", config.SetupGuideSettingsReviewedAt },
|
||||
{ "SetupGuideHardwareReviewedAt", config.SetupGuideHardwareReviewedAt },
|
||||
{ "SetupGuideDismissedAt", config.SetupGuideDismissedAt },
|
||||
{ "SetupGuideDismissedBy", config.SetupGuideDismissedBy },
|
||||
{ "ContactEmail", config.ContactEmail },
|
||||
{ "CompanyName", config.CompanyName },
|
||||
{ "Disabled", config.Disabled },
|
||||
@@ -865,6 +870,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
DpaAcceptancePending = entity.GetBoolean("DpaAcceptancePending") ?? false,
|
||||
DpaAcceptedBy = entity.GetString("DpaAcceptedBy"),
|
||||
DpaAcceptedAt = entity.GetDateTime("DpaAcceptedAt"),
|
||||
SetupGuideSettingsReviewedAt = entity.GetDateTime("SetupGuideSettingsReviewedAt"),
|
||||
SetupGuideHardwareReviewedAt = entity.GetDateTime("SetupGuideHardwareReviewedAt"),
|
||||
SetupGuideDismissedAt = entity.GetDateTime("SetupGuideDismissedAt"),
|
||||
SetupGuideDismissedBy = entity.GetString("SetupGuideDismissedBy"),
|
||||
ContactEmail = entity.GetString("ContactEmail"),
|
||||
CompanyName = entity.GetString("CompanyName"),
|
||||
Disabled = entity.GetBoolean("Disabled") ?? false,
|
||||
|
||||
+5
-2
@@ -3,9 +3,11 @@ using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
@@ -63,12 +65,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, int maxResults = 100)
|
||||
public async Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, DistressErrorType errorType, int maxResults = 100)
|
||||
{
|
||||
var pk = tenantId.ToLowerInvariant();
|
||||
var result = new List<DistressReportEntry>();
|
||||
var filter = TableClient.CreateQueryFilter($"PartitionKey eq {pk} and ErrorType eq {errorType.ToString()}");
|
||||
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(e => e.PartitionKey == pk, maxPerPage: maxResults))
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults)))
|
||||
{
|
||||
result.Add(MapToEntry(entity));
|
||||
if (result.Count >= maxResults)
|
||||
|
||||
+3
@@ -66,6 +66,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<TenantStats?> GetTenantStatsAsync(string tenantId)
|
||||
=> _storage.GetTenantStatsAsync(tenantId);
|
||||
|
||||
public Task<bool> TenantHasEnrollmentsAsync(string tenantId)
|
||||
=> _storage.TenantHasEnrollmentsAsync(tenantId);
|
||||
|
||||
public Task<List<TenantStats>> GetAllTenantStatsAsync()
|
||||
=> _storage.GetAllTenantStatsAsync();
|
||||
|
||||
|
||||
+20
-28
@@ -86,36 +86,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
// Single-category path: PK-targeted query, RowKey is reverse-tick →
|
||||
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
// Single-category path: PK-targeted query, RowKey is reverse-tick →
|
||||
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
var filter = BuildFilter(category, dateFrom, dateTo, filters);
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: _table,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
var filter = BuildFilter(category, dateFrom, dateTo, filters);
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: _table,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
|
||||
var page = new List<OpsEventEntry>(entities.Count);
|
||||
foreach (var entity in entities) page.Add(MapToEntry(entity));
|
||||
return new RawPage<OpsEventEntry>(page, nextRawToken);
|
||||
}
|
||||
var page = new List<OpsEventEntry>(entities.Count);
|
||||
foreach (var entity in entities) page.Add(MapToEntry(entity));
|
||||
return new RawPage<OpsEventEntry>(page, nextRawToken);
|
||||
}
|
||||
|
||||
// All-category path: per-partition fan-out + merge-sort. Azure pages
|
||||
// cross-partition queries by (PK asc, RK asc), so without this fan-out
|
||||
// the first page would come entirely from the alphabetically-first
|
||||
// category — defeating "newest first globally". Mirrors the same
|
||||
// pattern used for cross-tenant session queries.
|
||||
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get ops events page");
|
||||
return RawPage<OpsEventEntry>.Empty;
|
||||
}
|
||||
// All-category path: per-partition fan-out + merge-sort. Azure pages
|
||||
// cross-partition queries by (PK asc, RK asc), so without this fan-out
|
||||
// the first page would come entirely from the alphabetically-first
|
||||
// category — defeating "newest first globally". Mirrors the same
|
||||
// pattern used for cross-tenant session queries.
|
||||
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
|
||||
}
|
||||
|
||||
// Fixed list, owned by OpsEventCategory.All so it cannot drift from the vocabulary. It did
|
||||
@@ -135,7 +127,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
var filter = BuildFilterWithRowKeyBound(category, dateFrom, dateTo, bound, filters);
|
||||
var read = new List<(string RowKey, OpsEventEntry Item)>();
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: limit, cancellationToken: ct))
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(limit), cancellationToken: ct))
|
||||
{
|
||||
read.Add((e.RowKey, MapToEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
|
||||
+43
-3
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -16,15 +18,25 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private readonly ILogger<GetTenantFeatureFlagsFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AppHomingService _appHomingService;
|
||||
private readonly IMetricsRepository _metricsRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Tenants whose enrollment counter was seen above zero. The counter only grows, so a seen
|
||||
/// "true" costs no further read on this instance. An offboarded and re-onboarded tenant starts
|
||||
/// at zero again; until this instance recycles, its setup guide then stays hidden here.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, bool> KnownEnrolledTenants = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public GetTenantFeatureFlagsFunction(
|
||||
ILogger<GetTenantFeatureFlagsFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AppHomingService appHomingService)
|
||||
AppHomingService appHomingService,
|
||||
IMetricsRepository metricsRepository)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_appHomingService = appHomingService;
|
||||
_metricsRepository = metricsRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -50,9 +62,13 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
var appHomingFunnelActive = await _appHomingService.IsFunnelEligibleAsync(config);
|
||||
// Only the setup guide needs the enrollment history; a hidden guide skips the read.
|
||||
var hasEnrollmentSessions = config.SetupGuideDismissedAt == null
|
||||
? await HasEnrollmentSessionsAsync(requestCtx.TargetTenantId)
|
||||
: null;
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive));
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive, hasEnrollmentSessions));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -61,13 +77,31 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Null when the counter could not be read: the guide then stays hidden rather than guessing.</summary>
|
||||
private async Task<bool?> HasEnrollmentSessionsAsync(string tenantId)
|
||||
{
|
||||
if (KnownEnrolledTenants.ContainsKey(tenantId)) return true;
|
||||
try
|
||||
{
|
||||
var has = await _metricsRepository.TenantHasEnrollmentsAsync(tenantId);
|
||||
if (has) KnownEnrolledTenants.TryAdd(tenantId, true);
|
||||
return has;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Feature flags: enrollment history of tenant {TenantId} unknown", tenantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects the member-readable subset of <see cref="TenantConfiguration"/>.
|
||||
/// Pulled out as a static method so unit tests can verify field-level mapping
|
||||
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
|
||||
/// read-time edition resolution (trial expiry degrades automatically).
|
||||
/// </summary>
|
||||
internal static TenantFeatureFlagsResponse BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false)
|
||||
internal static TenantFeatureFlagsResponse BuildPayload(
|
||||
TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false, bool? hasEnrollmentSessions = null)
|
||||
{
|
||||
// Edition + source in one resolution: Pro from the plan, from a trial, or conferred by the
|
||||
// managing tenant ("msp" — shown as "Pro (MSP)", entitlements without the delegation right).
|
||||
@@ -126,6 +160,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
ContactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
|
||||
// Same contract for the second half of the contact profile: boolean only.
|
||||
CompanyNameSet = !string.IsNullOrWhiteSpace(config.CompanyName),
|
||||
// Setup guide checklist (dashboard card, sidebar entry): done states and the
|
||||
// dismissal. Booleans only, derived from the row and the enrollment counter.
|
||||
SetupGuideSettingsDone = SetupGuideState.SettingsDone(config),
|
||||
SetupGuideHardwareDone = SetupGuideState.HardwareDone(config),
|
||||
SetupGuideDismissed = config.SetupGuideDismissedAt != null,
|
||||
HasEnrollmentSessions = hasEnrollmentSessions,
|
||||
Entitlements = new TenantFeatureEntitlements
|
||||
{
|
||||
RetentionCapDays = entitlements.RetentionCapDays,
|
||||
|
||||
@@ -223,7 +223,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return true;
|
||||
}, "plan", "plan tier change");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
@@ -406,28 +406,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private static IEnumerable<string> KnownUsagePlanNames(IEnumerable<PlanTierDefinition> definitions)
|
||||
=> definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The response for an <see cref="TenantConfigurationService.UpdateAsync"/> outcome that wrote
|
||||
/// nothing for a reason other than the caller's own decision; null for Updated and Declined.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData?> UpdateFailureAsync(HttpRequestData req, TenantConfigUpdate update)
|
||||
{
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// The answer the suspension gate gives once its cache has caught up with the tombstone.
|
||||
return await req.ErrorAsync(HttpStatusCode.Forbidden,
|
||||
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
|
||||
case TenantConfigUpdateStatus.Conflict:
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict,
|
||||
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure verdict for the self-service trial start; null = allowed. Order matters and is
|
||||
/// test-pinned: the terminal conditions (trial consumed, already Pro) win over the
|
||||
@@ -511,7 +489,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return true;
|
||||
}, "plan", "self-service trial start");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
if (deny is { } refused)
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict, refused.Code, refused.Message);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// The setup guide's markers on the tenant row. The guide applies settings through the field
|
||||
/// PATCH like any settings card; this endpoint only stamps what the PATCH cannot: that an admin
|
||||
/// finished a step, or hid the guide for the whole tenant. Server time and the caller's UPN.
|
||||
/// </summary>
|
||||
public class SetupGuideFunction
|
||||
{
|
||||
internal const string StepSettings = "settings";
|
||||
internal const string StepHardware = "hardware";
|
||||
internal const string StepDismiss = "dismiss";
|
||||
internal const string StepRestore = "restore";
|
||||
|
||||
private readonly ILogger<SetupGuideFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
|
||||
public SetupGuideFunction(
|
||||
ILogger<SetupGuideFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/setup-guide — TenantAdminOrGA (catalog-enforced), refused in
|
||||
/// view-as. Body <c>{ "step": "settings" | "hardware" | "dismiss" | "restore" }</c>. A step that
|
||||
/// is already recorded writes nothing and answers with the current markers.
|
||||
/// </summary>
|
||||
[Function("RecordSetupGuideStep")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/setup-guide")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var read = await req.ReadAsync<SetupGuideStepRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
|
||||
var step = NormalizeStep(read.Value!.Step);
|
||||
if (step == null)
|
||||
return await req.BadRequestAsync("step must be one of: settings, hardware, dismiss, restore.");
|
||||
|
||||
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var update = await _configService.UpdateAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
row => ApplyStep(row, step, caller, nowUtc),
|
||||
"setup-guide",
|
||||
$"setup guide: {step}");
|
||||
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"UPDATE",
|
||||
"SetupGuide",
|
||||
requestCtx.TargetTenantId,
|
||||
caller,
|
||||
new Dictionary<string, string> { { "Step", step } });
|
||||
}
|
||||
|
||||
return await req.OkAsync(new SetupGuideStateResponse
|
||||
{
|
||||
Success = true,
|
||||
SettingsReviewedAt = config.SetupGuideSettingsReviewedAt,
|
||||
HardwareReviewedAt = config.SetupGuideHardwareReviewedAt,
|
||||
DismissedAt = config.SetupGuideDismissedAt,
|
||||
DismissedBy = config.SetupGuideDismissedBy,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "RecordSetupGuideStep");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The known step in lower case, or null.</summary>
|
||||
internal static string? NormalizeStep(string? step)
|
||||
{
|
||||
var value = step?.Trim().ToLowerInvariant();
|
||||
return value is StepSettings or StepHardware or StepDismiss or StepRestore ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps <paramref name="step"/> on the fresh row inside the conditional write. False (nothing to
|
||||
/// write) when the step is already recorded — the first completion stays the one on record.
|
||||
/// </summary>
|
||||
internal static bool ApplyStep(TenantConfiguration row, string step, string caller, DateTime nowUtc)
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case StepSettings:
|
||||
if (row.SetupGuideSettingsReviewedAt != null) return false;
|
||||
row.SetupGuideSettingsReviewedAt = nowUtc;
|
||||
break;
|
||||
case StepHardware:
|
||||
if (row.SetupGuideHardwareReviewedAt != null) return false;
|
||||
row.SetupGuideHardwareReviewedAt = nowUtc;
|
||||
break;
|
||||
case StepDismiss:
|
||||
if (row.SetupGuideDismissedAt != null) return false;
|
||||
row.SetupGuideDismissedAt = nowUtc;
|
||||
row.SetupGuideDismissedBy = caller;
|
||||
break;
|
||||
case StepRestore:
|
||||
if (row.SetupGuideDismissedAt == null) return false;
|
||||
row.SetupGuideDismissedAt = null;
|
||||
row.SetupGuideDismissedBy = null;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
row.UpdatedBy = caller;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -890,6 +890,12 @@ public class AuthFunction
|
||||
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
|
||||
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc,
|
||||
DpaAcceptancePending = tenantConfig.DpaAcceptancePending,
|
||||
// Navbar label, for the callers the config GET serves too (a tenant role, or a Global
|
||||
// Admin); a roleless member only gets the Progress Portal. In view-as tenantConfig is
|
||||
// the viewed tenant's row and the role the emulated one.
|
||||
CompanyName = (role != null || isGlobalAdmin) && !string.IsNullOrWhiteSpace(tenantConfig.CompanyName)
|
||||
? tenantConfig.CompanyName.Trim()
|
||||
: null,
|
||||
ViewAs = viewAs
|
||||
}, needsAutoAdmin);
|
||||
}
|
||||
|
||||
@@ -148,8 +148,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
// Gate 5: Tenant existence check (cached — cheap O(1) lookup)
|
||||
var (_, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
// Gate 5: Tenant existence check (cached — cheap O(1) lookup). The config also decides
|
||||
// the effective error type below.
|
||||
var (tenantConfig, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
if (!exists)
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
@@ -223,11 +224,14 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
var espPhase = SanitizeToken(report.EspPhase, MaxEspPhaseLength, EspPhasePattern);
|
||||
var deviceBootUtc = SanitizeDeviceBoot(report.DeviceBootUtc, report.Timestamp);
|
||||
|
||||
var errorType = ResolveErrorType(
|
||||
report.ErrorType, report.HttpStatusCode, manufacturer, model, report.CertSourceState, tenantConfig);
|
||||
|
||||
// Persist to Table Storage
|
||||
var entry = new DistressReportEntry
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ErrorType = report.ErrorType.ToString(),
|
||||
ErrorType = errorType.ToString(),
|
||||
Manufacturer = manufacturer,
|
||||
Model = model,
|
||||
SerialNumber = serialNumber,
|
||||
@@ -256,28 +260,24 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// Notify tenant channels for hardware whitelist rejections (opt-in). Durable via
|
||||
// the notification-dispatch queue — a fire-and-forget send after the response was
|
||||
// not counted as open work by the host and could be dropped on scale-in.
|
||||
if (report.ErrorType == DistressErrorType.HardwareNotAllowed
|
||||
if (errorType == DistressErrorType.HardwareNotAllowed
|
||||
&& _hardwareThrottle.ShouldNotify(tenantId, manufacturer, model))
|
||||
{
|
||||
try
|
||||
{
|
||||
var (config, configExists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
if (configExists)
|
||||
var hwChannels = tenantConfig.GetNotificationChannels()
|
||||
.Where(c => c.Enabled && c.NotifyOnHardwareRejection)
|
||||
.ToList();
|
||||
if (hwChannels.Count > 0)
|
||||
{
|
||||
var hwChannels = config!.GetNotificationChannels()
|
||||
.Where(c => c.Enabled && c.NotifyOnHardwareRejection)
|
||||
.ToList();
|
||||
if (hwChannels.Count > 0)
|
||||
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
|
||||
await _notificationProducer.EnqueueAsync(new NotificationDispatchEnvelope
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
|
||||
await _notificationProducer.EnqueueAsync(new NotificationDispatchEnvelope
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ChannelIds = hwChannels.Select(c => c.Id).ToList(),
|
||||
Alert = alert,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
TenantId = tenantId,
|
||||
ChannelIds = hwChannels.Select(c => c.Id).ToList(),
|
||||
Alert = alert,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
@@ -289,7 +289,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// In-app bell notification on first-ever hardware rejection per (tenant, manufacturer, model).
|
||||
// Lifetime dedup via tracker table — independent of the webhook 24h in-memory throttle so
|
||||
// tenant admins see a one-shot persistent notification even if the webhook is not configured.
|
||||
if (report.ErrorType == DistressErrorType.HardwareNotAllowed
|
||||
if (errorType == DistressErrorType.HardwareNotAllowed
|
||||
&& !string.IsNullOrEmpty(manufacturer) && !string.IsNullOrEmpty(model))
|
||||
{
|
||||
try
|
||||
@@ -318,7 +318,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// No webhook — volume is tiny and the remediation is not time-critical. Reports
|
||||
// without a serial number cannot be deduped and fire no bell; they still appear
|
||||
// in the panel.
|
||||
if (report.ErrorType == DistressErrorType.TpmPssUnsupported
|
||||
if (errorType == DistressErrorType.TpmPssUnsupported
|
||||
&& !string.IsNullOrEmpty(serialNumber))
|
||||
{
|
||||
try
|
||||
@@ -343,8 +343,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
|
||||
// Structured log (Warning, not Critical — data is unverified)
|
||||
_logger.LogWarning(
|
||||
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
|
||||
report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
|
||||
"AgentDistress [{ErrorType}] reported={ReportedErrorType} tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
|
||||
errorType, report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
|
||||
report.HttpStatusCode, agentVersion, osBuild ?? "n/a", espPhase ?? "n/a", oobeState ?? "n/a",
|
||||
deviceBootUtc?.ToString("O") ?? "n/a", tpmVersion ?? "n/a",
|
||||
certSourceState ?? "n/a", certThumbprint ?? "n/a", message);
|
||||
@@ -354,7 +354,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_telemetryClient.TrackEvent("AgentDistressReport", new Dictionary<string, string>
|
||||
{
|
||||
["TenantId"] = tenantId,
|
||||
["ErrorType"] = report.ErrorType.ToString(),
|
||||
["ErrorType"] = errorType.ToString(),
|
||||
["ReportedErrorType"] = report.ErrorType.ToString(),
|
||||
["Manufacturer"] = manufacturer ?? string.Empty,
|
||||
["Model"] = model ?? string.Empty,
|
||||
["SerialNumber"] = serialNumber ?? string.Empty,
|
||||
@@ -387,6 +388,31 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The error type to store and act on. The agent reports every backend 403 as
|
||||
/// <see cref="DistressErrorType.DeviceNotRegistered"/>, but <c>SecurityValidator</c> checks the
|
||||
/// hardware whitelist before it looks up the device's registration: a 403 for hardware
|
||||
/// outside the whitelist is a hardware rejection, and only as one does it reach the bell,
|
||||
/// the webhook and the rejected-hardware list. The conditions follow the validator's order:
|
||||
/// a suspended tenant, a tenant without any validation and bootstrap-token auth (no
|
||||
/// certificate) are refused or let through before the hardware check, so their 403 stays as
|
||||
/// reported. If that order changes, this has to change with it.
|
||||
/// </summary>
|
||||
internal static DistressErrorType ResolveErrorType(
|
||||
DistressErrorType reported, int? httpStatusCode, string? manufacturer, string? model,
|
||||
DistressCertSourceState? certSourceState, TenantConfiguration config)
|
||||
{
|
||||
if (reported != DistressErrorType.DeviceNotRegistered || httpStatusCode != 403) return reported;
|
||||
if (string.IsNullOrEmpty(manufacturer) || string.IsNullOrEmpty(model)) return reported;
|
||||
if (config.IsCurrentlyDisabled()) return reported;
|
||||
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests) return reported;
|
||||
if (certSourceState == DistressCertSourceState.NoCertInStore) return reported;
|
||||
|
||||
var hardware = HardwareWhitelistValidator.ValidateHardware(
|
||||
manufacturer, model, config.GetManufacturerWhitelist(), config.GetModelWhitelist());
|
||||
return hardware.IsValid ? reported : DistressErrorType.HardwareNotAllowed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a distress report timestamp is within acceptable range.
|
||||
/// Rejects timestamps older than 24 hours or more than 5 minutes in the future.
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,7 +11,7 @@ namespace AutopilotMonitor.Functions.Functions.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Device management from the portal: list (own devices for everyone on the tier, every device
|
||||
/// of the scope for its admins), remove (wipe push, then the row is gone) and test. Tenant routes
|
||||
/// of the scope for its admins), rename, remove (wipe push, then the row is gone) and test. Tenant routes
|
||||
/// under <c>push/devices…</c> (TenantAdminOrOperator), platform routes under
|
||||
/// <c>global/push/devices…</c> (GlobalAdminOnly).
|
||||
/// </summary>
|
||||
@@ -36,6 +37,12 @@ public class PushDevicesFunctions
|
||||
string deviceId)
|
||||
=> DeleteCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req));
|
||||
|
||||
[Function("RenamePushDevice")]
|
||||
public Task<HttpResponseData> Rename(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "push/devices/{deviceId}")] HttpRequestData req,
|
||||
string deviceId)
|
||||
=> RenameCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req));
|
||||
|
||||
[Function("TestPushDevice")]
|
||||
public Task<HttpResponseData> Test(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/devices/{deviceId}/test")] HttpRequestData req,
|
||||
@@ -53,6 +60,12 @@ public class PushDevicesFunctions
|
||||
string deviceId)
|
||||
=> DeleteCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req));
|
||||
|
||||
[Function("RenamePlatformPushDevice")]
|
||||
public Task<HttpResponseData> RenamePlatform(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/push/devices/{deviceId}")] HttpRequestData req,
|
||||
string deviceId)
|
||||
=> RenameCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req));
|
||||
|
||||
[Function("TestPlatformPushDevice")]
|
||||
public Task<HttpResponseData> TestPlatform(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/push/devices/{deviceId}/test")] HttpRequestData req,
|
||||
@@ -88,6 +101,25 @@ public class PushDevicesFunctions
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> RenameCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!PushFunctionSupport.IsPlausibleId(deviceId))
|
||||
return await req.NotFoundAsync("Device not found.");
|
||||
var read = await req.ReadAsync<RenamePushDeviceRequest>(PushFunctionSupport.LabelBodyMaxBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
var error = await _pairing.RenameAsync(ctx.Scope, deviceId, read.Value!.Label, ctx.Caller);
|
||||
if (error != PushOpError.None)
|
||||
return await PushFunctionSupport.ErrorAsync(req, error, error == PushOpError.NotFound ? "Device not found." : null);
|
||||
return req.CreateResponse(HttpStatusCode.NoContent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "RenamePushDevice");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> TestCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace AutopilotMonitor.Functions.Functions.Push;
|
||||
/// </summary>
|
||||
internal static class PushFunctionSupport
|
||||
{
|
||||
/// <summary>Body cap of the portal routes that carry only a device label.</summary>
|
||||
public const long LabelBodyMaxBytes = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// The tenant scope of a <c>push/…</c> route: the caller's own tenant. A Global Admin passes
|
||||
/// the TenantAdminOrOperator tier by bypass, so pairing re-checks the member role (K2/K5) and
|
||||
@@ -57,6 +60,7 @@ internal static class PushFunctionSupport
|
||||
PushOpError.NotConfigured => req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable,
|
||||
"The push channel is not configured on the platform."),
|
||||
PushOpError.Conflict => req.ConflictAsync(message ?? "The operation conflicts with the current state."),
|
||||
PushOpError.InvalidLabel => req.BadRequestAsync(message ?? "Enter a device name."),
|
||||
_ => req.InternalServerErrorAsync(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
|
||||
new InvalidOperationException("unmapped push error"), "Push"),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -114,7 +115,10 @@ public class PushPairingFunctions
|
||||
{
|
||||
if (!PushFunctionSupport.IsPlausibleId(pairingId))
|
||||
return await req.NotFoundAsync("Pairing not found.");
|
||||
var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller);
|
||||
// The body is optional: without one the device keeps the name it sent.
|
||||
var read = await req.ReadOptionalAsync<ConfirmPairingRequest>(PushFunctionSupport.LabelBodyMaxBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller, read.Value?.Label);
|
||||
return result.Ok
|
||||
? await req.OkAsync(result.Value!)
|
||||
: await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message ?? "Pairing not found.");
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.DeviceNotRegistered, maxResults: 200);
|
||||
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
|
||||
|
||||
return await req.OkAsync(new DeviceNotRegisteredResponse
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.HardwareNotAllowed, maxResults: 200);
|
||||
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
|
||||
|
||||
return await req.OkAsync(new HardwareRejectedResponse
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.TpmPssUnsupported, maxResults: 200);
|
||||
var tpmReports = reports.Where(IsTpmPssReport).ToList();
|
||||
|
||||
IReadOnlyDictionary<string, DateTime>? lastCertificateIngest = null;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the setup guide's two optional steps are done. A step counts as done when an admin
|
||||
/// finished it in the guide, or when the configuration already says so — a tenant that set
|
||||
/// things up by hand never sees those steps as open. The two required steps (a validation
|
||||
/// method, the first session) come from the configuration and the enrollment counter.
|
||||
/// </summary>
|
||||
internal static class SetupGuideState
|
||||
{
|
||||
private static readonly TenantConfiguration Defaults = new();
|
||||
|
||||
/// <summary>Recommended settings: timezone auto-set, the software inventory analyzer and a working diagnostics upload.</summary>
|
||||
internal static bool SettingsDone(TenantConfiguration config) =>
|
||||
config.SetupGuideSettingsReviewedAt != null
|
||||
|| (config.EnableTimezoneAutoSet == true
|
||||
&& config.EnableSoftwareInventoryAnalyzer == true
|
||||
&& DiagnosticsUploadConfigChange.IsConfigured(config));
|
||||
|
||||
/// <summary>Hardware: the whitelist was reviewed, or it differs from the default.</summary>
|
||||
internal static bool HardwareDone(TenantConfiguration config) =>
|
||||
config.SetupGuideHardwareReviewedAt != null
|
||||
|| !SameEntries(config.GetManufacturerWhitelist(), Defaults.GetManufacturerWhitelist())
|
||||
|| !SameEntries(config.GetModelWhitelist(), Defaults.GetModelWhitelist());
|
||||
|
||||
private static bool SameEntries(string[] a, string[] b) =>
|
||||
a.Length == b.Length
|
||||
&& a.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.SequenceEqual(b.OrderBy(x => x, StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>HTTP answers for a <see cref="TenantConfigurationService.UpdateAsync"/> outcome.</summary>
|
||||
internal static class TenantConfigUpdateResponses
|
||||
{
|
||||
/// <summary>
|
||||
/// The response for an outcome that wrote nothing for a reason other than the caller's own
|
||||
/// decision; null for Updated and Declined.
|
||||
/// </summary>
|
||||
internal static async Task<HttpResponseData?> FailureAsync(HttpRequestData req, TenantConfigUpdate update)
|
||||
{
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// The answer the suspension gate gives once its cache has caught up with the tombstone.
|
||||
return await req.ErrorAsync(HttpStatusCode.Forbidden,
|
||||
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
|
||||
case TenantConfigUpdateStatus.Conflict:
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict,
|
||||
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,78 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a change of the Local Admin Analyzer's allowed list
|
||||
/// (<see cref="TenantConfiguration.LocalAdminAllowedAccountsJson"/>). Only entries that are NEW
|
||||
/// against the stored list pass <see cref="LocalAdminAllowList.ValidateNewEntry"/>: a stored
|
||||
/// entry keeps its meaning, and the portal always sends the whole list. A new entry must also
|
||||
/// not repeat another entry's pattern ("X" next to "X|user" contradicts itself). Without this
|
||||
/// check the agent would ignore a malformed entry — and the MCP write path never saw the
|
||||
/// portal's "too broad" rule. Returns a user-facing reason, or null.
|
||||
/// </summary>
|
||||
internal static string? ValidateLocalAdminAllowedAccounts(string? candidateJson, string? existingJson)
|
||||
{
|
||||
if (string.Equals(candidateJson ?? string.Empty, existingJson ?? string.Empty, StringComparison.Ordinal)
|
||||
|| string.IsNullOrWhiteSpace(candidateJson))
|
||||
return null;
|
||||
|
||||
var candidate = ParseStringList(candidateJson);
|
||||
if (candidate == null)
|
||||
return "must be a JSON array of account names.";
|
||||
if (candidate.Count > LocalAdminAllowList.MaxEntries)
|
||||
return $"at most {LocalAdminAllowList.MaxEntries} entries are allowed.";
|
||||
|
||||
var stored = new HashSet<string>(ParseStringList(existingJson) ?? new List<string>(), StringComparer.Ordinal);
|
||||
for (var i = 0; i < candidate.Count; i++)
|
||||
{
|
||||
if (stored.Contains(candidate[i]))
|
||||
continue;
|
||||
|
||||
var entryError = LocalAdminAllowList.ValidateNewEntry(candidate[i]);
|
||||
if (entryError != null)
|
||||
return entryError;
|
||||
|
||||
var pattern = PatternOf(candidate[i]);
|
||||
for (var j = 0; j < candidate.Count; j++)
|
||||
{
|
||||
if (j != i && string.Equals(pattern, PatternOf(candidate[j]), StringComparison.OrdinalIgnoreCase))
|
||||
return $"\"{pattern}\" is listed twice.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string PatternOf(string entry)
|
||||
=> LocalAdminAllowList.TryParseEntry(entry, out var pattern, out _) ? pattern : entry;
|
||||
|
||||
/// <summary>A JSON array of strings, an empty list for blank input, or null when the input is anything else.</summary>
|
||||
private static List<string>? ParseStringList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array)
|
||||
return null;
|
||||
|
||||
var items = new List<string>();
|
||||
foreach (var item in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != System.Text.Json.JsonValueKind.String)
|
||||
return null;
|
||||
items.Add(item.GetString()!);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Generous for a CDN asset URL; bounds the command line the agent composes from it.
|
||||
internal const int MaxBrandingImageUrlLength = 2048;
|
||||
|
||||
@@ -223,6 +295,11 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
if (modelWhitelistError != null)
|
||||
return $"Invalid model whitelist: {modelWhitelistError}";
|
||||
|
||||
var allowedAccountsError = ValidateLocalAdminAllowedAccounts(
|
||||
candidate.LocalAdminAllowedAccountsJson, existing.LocalAdminAllowedAccountsJson);
|
||||
if (allowedAccountsError != null)
|
||||
return $"Invalid allowed local accounts: {allowedAccountsError}";
|
||||
|
||||
// Only validate the customer-supplied SAS URL when the tenant has actually selected
|
||||
// the CustomerSas destination — a stale value left over from a prior CustomerSas
|
||||
// configuration must not block a Hosted save (mirrors GetDiagnosticsUploadUrlFunction).
|
||||
|
||||
@@ -18,6 +18,16 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
/// </remarks>
|
||||
public static class AzureTablesPaginator
|
||||
{
|
||||
/// <summary>Largest <c>$top</c> Azure Tables accepts; a larger one is refused with 400 InvalidInput.</summary>
|
||||
public const int MaxServerPageSize = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>maxPerPage</c> for a read of up to <paramref name="rows"/> rows. Every query that
|
||||
/// passes a computed page size goes through here; the rows beyond one server page come
|
||||
/// from the following pages of the same enumeration.
|
||||
/// </summary>
|
||||
public static int ServerPageSize(int rows) => Math.Min(rows, MaxServerPageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a single page of entities. Honours <paramref name="pageSize"/>
|
||||
/// as both the SDK <c>maxPerPage</c> and the page-size hint when
|
||||
@@ -52,7 +62,7 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
|
||||
var query = client.QueryAsync<T>(
|
||||
filter: filter,
|
||||
maxPerPage: pageSize,
|
||||
maxPerPage: ServerPageSize(pageSize),
|
||||
select: selectArr,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
|
||||
@@ -546,9 +546,9 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.PushMaint
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.PushSessionWatchService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.IPushSessionWatchNotifier>(
|
||||
sp => sp.GetRequiredService<AutopilotMonitor.Functions.Services.Push.PushSessionWatchService>());
|
||||
// Push endpoints are per-device send capabilities: the dependency rows for the push services
|
||||
// keep scheme + host only (K20).
|
||||
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.PushDependencyRedactionProcessor>();
|
||||
// Push endpoints and webhook URLs are credentials: the dependency rows of the clients that send to
|
||||
// them keep scheme + host only (K20, D-424).
|
||||
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.CredentialUrlRedactionProcessor>();
|
||||
// Items a Global Admin's read-only "view as" request emits carry the viewed tenant as ViewAsTenantId.
|
||||
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.ViewAsTelemetryProcessor>();
|
||||
|
||||
|
||||
@@ -757,6 +757,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
// Self-service Pro trial — a tenant admin may start their own tenant's one-time
|
||||
// trial; the handler enforces the once-per-tenant + already-Pro conflicts (409).
|
||||
new("POST", "config/{tenantId}/trial", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/setup-guide", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("GET", "global/config/plan-tiers", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("PUT", "global/config/plan-tiers", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "feedback/all", EndpointPolicy.GlobalReadOrAdmin),
|
||||
@@ -778,6 +779,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "push/pairings/{pairingId}/confirm", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("POST", "push/pairings/{pairingId}/reject", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("GET", "push/devices", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("PATCH", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("DELETE", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("POST", "push/devices/{deviceId}/test", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt),
|
||||
new("GET", "sessions/{sessionId}/watch", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam),
|
||||
@@ -789,6 +791,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "global/push/pairings/{pairingId}/confirm", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/push/pairings/{pairingId}/reject", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/push/devices", EndpointPolicy.GlobalAdminOnly),
|
||||
new("PATCH", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("DELETE", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/push/devices/{deviceId}/test", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "notifications/dismiss-all", EndpointPolicy.TenantAdminOrGA, TenantScoping.Jwt),
|
||||
|
||||
+12
-1
@@ -13,6 +13,17 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
/// </summary>
|
||||
public static class NotificationAlertBuilder
|
||||
{
|
||||
public const string EnrollmentStartedEventType = "enrollment_started";
|
||||
public const string PreProvisioningResumedEventType = "preprovisioning_resumed";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the alert announces that a run has just begun (<see cref="BuildEnrollmentStartedAlert"/>):
|
||||
/// it is worth something only while the run is still going.
|
||||
/// </summary>
|
||||
public static bool IsStartAlert(string? eventType)
|
||||
=> string.Equals(eventType, EnrollmentStartedEventType, StringComparison.Ordinal)
|
||||
|| string.Equals(eventType, PreProvisioningResumedEventType, StringComparison.Ordinal);
|
||||
|
||||
public static NotificationAlert BuildEnrollmentAlert(
|
||||
string? deviceName,
|
||||
string? serialNumber,
|
||||
@@ -87,7 +98,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = isResume ? "preprovisioning_resumed" : "enrollment_started",
|
||||
EventType = isResume ? PreProvisioningResumedEventType : EnrollmentStartedEventType,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Severity = NotificationSeverity.Info,
|
||||
|
||||
+4
-1
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
@@ -11,7 +12,8 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
/// <summary>
|
||||
/// Registers the typed HttpClient of <see cref="WebhookNotificationService"/>. Its primary
|
||||
/// handler connects only through <see cref="SsrfGuard.ConnectToPublicAddress"/>: the webhook
|
||||
/// SSRF gate lives on the connection, so it covers every request this client sends.
|
||||
/// SSRF gate lives on the connection, so it covers every request this client sends. A webhook
|
||||
/// URL is a credential, so the client's dependency rows keep scheme + host only (D-424).
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddWebhookNotificationHttpClient(this IServiceCollection services) =>
|
||||
services.AddHttpClient<WebhookNotificationService>()
|
||||
@@ -22,6 +24,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
ConnectCallback = SsrfGuard.ConnectToPublicAddress,
|
||||
})
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
|
||||
.AddHttpMessageHandler(() => new CredentialUrlRedactionProcessor.MarkingHandler())
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,15 +383,27 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker
|
||||
|
||||
// ── Transport ───────────────────────────────────────────────────────────
|
||||
|
||||
private PushSendOptions BuildOptions(NotificationAlert alert, PushPayload payload)
|
||||
/// <summary>
|
||||
/// Urgency follows severity, except for a start (D-424): it is Info, but worth something only while
|
||||
/// the run is still going. Below <c>high</c> FCM holds a push while the phone dozes, and the run's
|
||||
/// verdict, which shares its session Topic, then replaces the waiting start at the push service.
|
||||
/// </summary>
|
||||
internal static PushUrgency UrgencyFor(NotificationAlert alert)
|
||||
{
|
||||
var urgency = alert.Severity switch
|
||||
if (NotificationAlertBuilder.IsStartAlert(alert.EventType))
|
||||
return PushUrgency.High;
|
||||
return alert.Severity switch
|
||||
{
|
||||
NotificationSeverity.Error => PushUrgency.High,
|
||||
NotificationSeverity.Warning => PushUrgency.High,
|
||||
NotificationSeverity.Success => PushUrgency.Normal,
|
||||
_ => PushUrgency.Low,
|
||||
};
|
||||
}
|
||||
|
||||
private PushSendOptions BuildOptions(NotificationAlert alert, PushPayload payload)
|
||||
{
|
||||
var urgency = UrgencyFor(alert);
|
||||
var ttl = string.Equals(alert.EventType, "test", StringComparison.OrdinalIgnoreCase) ? TimeSpan.FromMinutes(5) : DefaultTtl;
|
||||
return new PushSendOptions(ttl, urgency, PushAlertProjector.BuildTopic(payload.PortalUrl, _topicKey));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Push;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -15,7 +16,8 @@ public static class PushHttpClientRegistration
|
||||
/// retry policy — push services send Retry-After and throttle senders that re-POST in 2 s;
|
||||
/// the library classifies once and retries 5xx exactly once (K15). The sender is a singleton
|
||||
/// (it caches VAPID tokens per push-service origin), so the pooled connection lifetime keeps
|
||||
/// DNS changes from going unnoticed.
|
||||
/// DNS changes from going unnoticed. A push endpoint is a per-device send capability, so the
|
||||
/// client's dependency rows keep scheme + host only (K20, D-424).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPushSender(this IServiceCollection services)
|
||||
{
|
||||
@@ -27,7 +29,8 @@ public static class PushHttpClientRegistration
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
ConnectCallback = SsrfGuard.ConnectToPublicAddress,
|
||||
})
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(10));
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(10))
|
||||
.AddHttpMessageHandler(() => new CredentialUrlRedactionProcessor.MarkingHandler());
|
||||
|
||||
services.AddSingleton(sp => new WebPushSender(sp.GetRequiredService<IHttpClientFactory>().CreateClient(ClientName)));
|
||||
return services;
|
||||
|
||||
@@ -33,6 +33,7 @@ public enum PushOpError
|
||||
InvalidSubscription,
|
||||
NotConfigured,
|
||||
Conflict,
|
||||
InvalidLabel,
|
||||
}
|
||||
|
||||
public sealed record PushOpResult<T>(T? Value, PushOpError Error, string? Message = null)
|
||||
@@ -158,7 +159,11 @@ public class PushPairingService
|
||||
return PushOpResult<PairingStatusResponse>.Success(new PairingStatusResponse { Status = status, Device = dto });
|
||||
}
|
||||
|
||||
public async Task<PushOpResult<ConfirmPairingResponse>> ConfirmAsync(NotificationScope scope, string pairingId, PushCaller caller)
|
||||
/// <summary>
|
||||
/// <paramref name="label"/> is the name the owner chose in the confirm dialog; blank keeps the
|
||||
/// name the device sent.
|
||||
/// </summary>
|
||||
public async Task<PushOpResult<ConfirmPairingResponse>> ConfirmAsync(NotificationScope scope, string pairingId, PushCaller caller, string? label = null)
|
||||
{
|
||||
var grant = await GetOwnedGrantAsync(scope, pairingId, caller).ConfigureAwait(false);
|
||||
if (grant == null || grant.DeviceId == null)
|
||||
@@ -176,14 +181,23 @@ public class PushPairingService
|
||||
if (!await _repo.TryUpdateGrantAsync(grant).ConfigureAwait(false))
|
||||
return PushOpResult<ConfirmPairingResponse>.Fail(PushOpError.Conflict, "The pairing changed concurrently — reload and try again.");
|
||||
|
||||
await _repo.MutateDeviceAsync(scope.Key, device.DeviceId, _ => new Dictionary<string, object?>
|
||||
var chosenLabel = PushAlertProjector.Clean(label, Constants.Push.MaxLabelLength);
|
||||
await _repo.MutateDeviceAsync(scope.Key, device.DeviceId, _ =>
|
||||
{
|
||||
["Status"] = Constants.Push.DeviceStatus.Active,
|
||||
["StatusReason"] = null,
|
||||
["ConfirmedUtc"] = now,
|
||||
["StatusChangedUtc"] = now,
|
||||
var patch = new Dictionary<string, object?>
|
||||
{
|
||||
["Status"] = Constants.Push.DeviceStatus.Active,
|
||||
["StatusReason"] = null,
|
||||
["ConfirmedUtc"] = now,
|
||||
["StatusChangedUtc"] = now,
|
||||
};
|
||||
if (chosenLabel.Length > 0)
|
||||
patch["Label"] = chosenLabel;
|
||||
return patch;
|
||||
}).ConfigureAwait(false);
|
||||
device.Status = Constants.Push.DeviceStatus.Active;
|
||||
if (chosenLabel.Length > 0)
|
||||
device.Label = chosenLabel;
|
||||
|
||||
var paired = PushAlertProjector.SystemMessage("push_paired", "Device paired",
|
||||
$"This device now receives Autopilot Monitor alerts for {await ScopeNameAsync(scope).ConfigureAwait(false)}.",
|
||||
@@ -452,6 +466,34 @@ public class PushPairingService
|
||||
return PushOpError.None;
|
||||
}
|
||||
|
||||
/// <summary>Same access rule as <see cref="DeleteAsync"/>: the owner, or an admin of the scope.</summary>
|
||||
public async Task<PushOpError> RenameAsync(NotificationScope scope, string deviceId, string? label, PushCaller caller)
|
||||
{
|
||||
var device = await _repo.GetDeviceAsync(scope.Key, deviceId).ConfigureAwait(false);
|
||||
if (device == null || !(caller.IsScopeAdmin || IsOwn(device, caller)))
|
||||
return PushOpError.NotFound;
|
||||
|
||||
var clean = PushAlertProjector.Clean(label, Constants.Push.MaxLabelLength);
|
||||
if (clean.Length == 0)
|
||||
return PushOpError.InvalidLabel;
|
||||
|
||||
string? previous = null;
|
||||
var written = await _repo.MutateDeviceAsync(scope.Key, device.DeviceId, current =>
|
||||
{
|
||||
if (string.Equals(current.Label, clean, StringComparison.Ordinal))
|
||||
return null;
|
||||
previous = current.Label;
|
||||
return new Dictionary<string, object?> { ["Label"] = clean };
|
||||
}).ConfigureAwait(false);
|
||||
if (!written)
|
||||
return PushOpError.None;
|
||||
|
||||
device.Label = clean;
|
||||
await AuditAsync(scope, "UPDATE", device, caller, previous).ConfigureAwait(false);
|
||||
Track("renamed", scope, device.Platform);
|
||||
return PushOpError.None;
|
||||
}
|
||||
|
||||
public async Task<PushOpResult<TestWebhookNotificationResponse>> TestAsync(NotificationScope scope, string deviceId, PushCaller caller)
|
||||
{
|
||||
var device = await _repo.GetDeviceAsync(scope.Key, deviceId).ConfigureAwait(false);
|
||||
@@ -565,12 +607,14 @@ public class PushPairingService
|
||||
: parseError ?? "The subscription keys have the wrong shape.";
|
||||
}
|
||||
|
||||
private Task AuditAsync(NotificationScope scope, string action, PushDevice device, PushCaller caller)
|
||||
private Task AuditAsync(NotificationScope scope, string action, PushDevice device, PushCaller caller, string? previousLabel = null)
|
||||
{
|
||||
if (scope.IsPlatform)
|
||||
return Task.CompletedTask;
|
||||
return _maintenanceRepo.LogAuditEntryAsync(scope.Key, action, "PushDevice", device.DeviceId, caller.Upn,
|
||||
new Dictionary<string, string> { ["platform"] = device.Platform, ["label"] = device.Label, ["owner"] = device.OwnerUpn });
|
||||
var details = new Dictionary<string, string> { ["platform"] = device.Platform, ["label"] = device.Label, ["owner"] = device.OwnerUpn };
|
||||
if (previousLabel != null)
|
||||
details["previousLabel"] = previousLabel;
|
||||
return _maintenanceRepo.LogAuditEntryAsync(scope.Key, action, "PushDevice", device.DeviceId, caller.Upn, details);
|
||||
}
|
||||
|
||||
private void Track(string action, NotificationScope scope, string? platform = null, string? reason = null)
|
||||
|
||||
+64
-80
@@ -235,52 +235,44 @@ namespace AutopilotMonitor.Functions.Services
|
||||
string? filter, int pageSize, string? continuation)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
|
||||
|
||||
// RowKey scheme is `!{revtick}_{guid}` so Azure-native (PK asc, RK asc)
|
||||
// already yields newest-first within the partition. No re-sort needed.
|
||||
// Legacy entries (bare GUID RowKey) sort *after* all new entries because
|
||||
// '!' (0x21) precedes hex digits — they appear at the tail of the result
|
||||
// in undefined order until they age out of the date window.
|
||||
//
|
||||
// Back-fill loop: a server-side exclusion (e.g. "exclude
|
||||
// deletions") can make a single Azure page return far fewer than
|
||||
// pageSize matches — during a cleanup sweep the first scanned
|
||||
// rows may all be deletion bookkeeping. We follow the opaque
|
||||
// continuation until we have at least pageSize matches or the
|
||||
// partition is exhausted, then return the last consumed page's
|
||||
// token. Accumulating whole Azure pages (rather than trimming to
|
||||
// pageSize) keeps that token gap-free, so we may overshoot by up
|
||||
// to one Azure page — harmless for the caller. Without an
|
||||
// exclusion the first page already fills, so the loop exits
|
||||
// immediately and behaviour is unchanged.
|
||||
var logs = new List<AuditLogEntry>(pageSize);
|
||||
string? nextRawToken = continuation;
|
||||
var roundTrips = 0;
|
||||
do
|
||||
{
|
||||
var (entities, token) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: tableClient,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: nextRawToken);
|
||||
foreach (var entity in entities) logs.Add(MapToAuditLogEntry(entity));
|
||||
nextRawToken = token;
|
||||
roundTrips++;
|
||||
}
|
||||
while (logs.Count < pageSize
|
||||
&& !string.IsNullOrEmpty(nextRawToken)
|
||||
&& roundTrips < AuditLogMaxFillRoundTrips);
|
||||
|
||||
return new RawPage<AuditLogEntry>(logs, nextRawToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
// RowKey scheme is `!{revtick}_{guid}` so Azure-native (PK asc, RK asc)
|
||||
// already yields newest-first within the partition. No re-sort needed.
|
||||
// Legacy entries (bare GUID RowKey) sort *after* all new entries because
|
||||
// '!' (0x21) precedes hex digits — they appear at the tail of the result
|
||||
// in undefined order until they age out of the date window.
|
||||
//
|
||||
// Back-fill loop: a server-side exclusion (e.g. "exclude
|
||||
// deletions") can make a single Azure page return far fewer than
|
||||
// pageSize matches — during a cleanup sweep the first scanned
|
||||
// rows may all be deletion bookkeeping. We follow the opaque
|
||||
// continuation until we have at least pageSize matches or the
|
||||
// partition is exhausted, then return the last consumed page's
|
||||
// token. Accumulating whole Azure pages (rather than trimming to
|
||||
// pageSize) keeps that token gap-free, so we may overshoot by up
|
||||
// to one Azure page — harmless for the caller. Without an
|
||||
// exclusion the first page already fills, so the loop exits
|
||||
// immediately and behaviour is unchanged.
|
||||
var logs = new List<AuditLogEntry>(pageSize);
|
||||
string? nextRawToken = continuation;
|
||||
var roundTrips = 0;
|
||||
do
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get audit logs page");
|
||||
return RawPage<AuditLogEntry>.Empty;
|
||||
var (entities, token) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: tableClient,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: nextRawToken);
|
||||
foreach (var entity in entities) logs.Add(MapToAuditLogEntry(entity));
|
||||
nextRawToken = token;
|
||||
roundTrips++;
|
||||
}
|
||||
while (logs.Count < pageSize
|
||||
&& !string.IsNullOrEmpty(nextRawToken)
|
||||
&& roundTrips < AuditLogMaxFillRoundTrips);
|
||||
|
||||
return new RawPage<AuditLogEntry>(logs, nextRawToken);
|
||||
}
|
||||
|
||||
private async Task<RawPage<AuditLogEntry>> FetchAllAuditLogsPageAsync(
|
||||
@@ -288,43 +280,35 @@ namespace AutopilotMonitor.Functions.Services
|
||||
AuditLogQueryFilters? filters = null)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
// Tenants come from TenantConfiguration (1 row per tenant — cheap).
|
||||
// PLUS the synthetic global-tenant partition (Constants.AuditGlobalTenantId)
|
||||
// where platform-action audits are written from TenantOffboardFunction,
|
||||
// VersionBlockFunction, PatchAdminConfigurationFunction, … TenantConfiguration
|
||||
// has no row for it because it's a virtual partition, so it'd be
|
||||
// silently skipped without this explicit add.
|
||||
var tenantIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Tenants come from TenantConfiguration (1 row per tenant — cheap).
|
||||
// PLUS the synthetic global-tenant partition (Constants.AuditGlobalTenantId)
|
||||
// where platform-action audits are written from TenantOffboardFunction,
|
||||
// VersionBlockFunction, PatchAdminConfigurationFunction, … TenantConfiguration
|
||||
// has no row for it because it's a virtual partition, so it'd be
|
||||
// silently skipped without this explicit add.
|
||||
var tenantIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
Constants.AuditGlobalTenantId,
|
||||
};
|
||||
foreach (var tenantId in await GetTenantIdsCachedAsync())
|
||||
{
|
||||
tenantIds.Add(tenantId);
|
||||
}
|
||||
Constants.AuditGlobalTenantId,
|
||||
};
|
||||
foreach (var tenantId in await GetTenantIdsCachedAsync())
|
||||
{
|
||||
tenantIds.Add(tenantId);
|
||||
}
|
||||
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
|
||||
return await PerPartitionFanOutMerge.FetchPageAsync<AuditLogEntry>(
|
||||
tenantIds, pageSize, continuation,
|
||||
async (tenantId, bound, limit, ct) =>
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
|
||||
return await PerPartitionFanOutMerge.FetchPageAsync<AuditLogEntry>(
|
||||
tenantIds, pageSize, continuation,
|
||||
async (tenantId, bound, limit, ct) =>
|
||||
{
|
||||
var filter = BuildAuditLogFilterWithRowKeyBound(tenantId, dateFrom, dateTo, bound, excludeDeletions, filters);
|
||||
var read = new List<(string RowKey, AuditLogEntry Item)>();
|
||||
await foreach (var e in tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(limit), cancellationToken: ct))
|
||||
{
|
||||
var filter = BuildAuditLogFilterWithRowKeyBound(tenantId, dateFrom, dateTo, bound, excludeDeletions, filters);
|
||||
var read = new List<(string RowKey, AuditLogEntry Item)>();
|
||||
await foreach (var e in tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: limit, cancellationToken: ct))
|
||||
{
|
||||
read.Add((e.RowKey, MapToAuditLogEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
}
|
||||
return read;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get all audit logs page");
|
||||
return RawPage<AuditLogEntry>.Empty;
|
||||
}
|
||||
read.Add((e.RowKey, MapToAuditLogEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
}
|
||||
return read;
|
||||
});
|
||||
}
|
||||
|
||||
// Suppress automated maintenance entries (SessionTimeout, DataRetentionCleanup, …)
|
||||
@@ -482,11 +466,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Bound the server read: cap page size to maxResults and stop enumerating once we
|
||||
// have that many. Azure Tables has no server-side OrderBy, so this returns the first
|
||||
// maxResults rows in RowKey order — the same ordering as the previous full scan, it
|
||||
// just stops reading once the cap is reached. Unbounded callers pass int.MaxValue.
|
||||
// just stops reading once the cap is reached. Unbounded callers pass int.MaxValue,
|
||||
// which caps to the server default of 1000.
|
||||
// With excludeInFlightDeletions the scan continues past locked rows, so it may read
|
||||
// more pages than maxResults — bounded by the tenant's eligible backlog.
|
||||
int? maxPerPage = maxResults == int.MaxValue ? (int?)null : maxResults;
|
||||
var query = tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: maxPerPage);
|
||||
var query = tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults));
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
int skippedInFlight = 0;
|
||||
|
||||
@@ -1279,6 +1279,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Gets the cumulative per-tenant counters, or null if none were recorded yet.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Whether the tenant's enrollment counter is above zero. Unlike <see cref="GetTenantStatsAsync"/>,
|
||||
/// which answers null both for a missing row and for a failed read, a storage error throws here.
|
||||
/// </summary>
|
||||
public async Task<bool> TenantHasEnrollmentsAsync(string tenantId)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PlatformStats);
|
||||
var response = await tableClient.GetEntityIfExistsAsync<TableEntity>(
|
||||
tenantId, TenantStatsRowKey, select: new[] { nameof(TenantStats.TotalEnrollments) });
|
||||
return response.HasValue && (response.Value!.GetInt64(nameof(TenantStats.TotalEnrollments)) ?? 0) > 0;
|
||||
}
|
||||
|
||||
public async Task<TenantStats?> GetTenantStatsAsync(string tenantId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -3095,7 +3095,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
filter: $"PartitionKey eq '{partitionKey}'",
|
||||
maxPerPage: maxResults
|
||||
maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults)
|
||||
);
|
||||
|
||||
await foreach (var entity in query)
|
||||
@@ -3167,7 +3167,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
maxPerPage: maxResults
|
||||
maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults)
|
||||
);
|
||||
|
||||
await foreach (var entity in query)
|
||||
@@ -3316,7 +3316,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
maxPerPage: maxResults);
|
||||
maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults));
|
||||
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
|
||||
@@ -104,13 +104,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// with dedicated endpoints (plan/trial), and system-written provenance:
|
||||
/// HomedAppClientId caused the 2026-07-31 prod incident when a stale round-trip
|
||||
/// reverted it — only the app-homing flow writes it; LastAuthClientId*/OnboardedBy/
|
||||
/// OnboardedAt/DpaVersion/DpaAccept* are AuthFunction-owned; LastUpdated/UpdatedBy are stamped server-side.
|
||||
/// OnboardedAt/DpaVersion/DpaAccept* are AuthFunction-owned; SetupGuide* belong to the setup-guide
|
||||
/// endpoint; LastUpdated/UpdatedBy are stamped server-side.
|
||||
/// </summary>
|
||||
internal static readonly HashSet<string> BaseDeniedFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"TenantId", "DomainName", "PartitionKey", "RowKey", "Timestamp", "ETag",
|
||||
"LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy", "DpaVersion",
|
||||
"DpaAcceptancePending", "DpaAcceptedBy", "DpaAcceptedAt",
|
||||
"SetupGuideSettingsReviewedAt", "SetupGuideHardwareReviewedAt", "SetupGuideDismissedAt", "SetupGuideDismissedBy",
|
||||
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
|
||||
"GraphAccessRejectedSince", "GraphAccessRejectedCode", "GraphAccessRejectionConfirmedAt",
|
||||
"IntuneDeviceBindingPermissionMissingSince", "IntuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
@@ -169,6 +171,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"CloudPcPermissionMissingSince", "CloudPcPermissionMissingConfirmedAt",
|
||||
"OnboardedBy", "OnboardedAt", "DpaVersion",
|
||||
"DpaAcceptancePending", "DpaAcceptedBy", "DpaAcceptedAt",
|
||||
"SetupGuideSettingsReviewedAt", "SetupGuideHardwareReviewedAt", "SetupGuideDismissedAt", "SetupGuideDismissedBy",
|
||||
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
|
||||
"ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride", "PayingCustomer",
|
||||
};
|
||||
@@ -518,6 +521,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"LastUpdated" or "UpdatedBy" => "stamped server-side on every write",
|
||||
"OnboardedAt" or "OnboardedBy" or "DpaVersion" => "system-owned onboarding provenance (set at first login)",
|
||||
"DpaAcceptancePending" or "DpaAcceptedBy" or "DpaAcceptedAt" => "system-owned — written by the onboarding and the Terms + DPA acceptance",
|
||||
"SetupGuideSettingsReviewedAt" or "SetupGuideHardwareReviewedAt" or "SetupGuideDismissedAt" or "SetupGuideDismissedBy"
|
||||
=> "system-owned — written by the setup guide endpoint",
|
||||
"HomedAppClientId" => "system-owned — written only by the app-homing flow",
|
||||
"LastAuthClientId" or "LastAuthClientIdSince" => "system-owned auth provenance",
|
||||
"GraphAccessRejectedSince" or "GraphAccessRejectedCode" or "GraphAccessRejectionConfirmedAt"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Some outbound URLs are credentials: a Web Push endpoint is a per-device send capability (K20), and a
|
||||
/// webhook URL lets whoever holds it post into the customer's channel (Discord, Slack and Telegram carry
|
||||
/// the token in the path, a Teams workflow the signature in the query, a generic webhook anything). The
|
||||
/// HttpClients that send to such URLs carry <see cref="MarkingHandler"/>, which marks the async flow of
|
||||
/// each request; every dependency row emitted inside a marked flow keeps scheme + host only, in
|
||||
/// <c>Data</c> and <c>Name</c> (D-424). Target, ResultCode and Duration stay, which is all
|
||||
/// <c>/platform-health</c> reads. The mark sits on the client, as the SSRF gate sits on its connection:
|
||||
/// it covers every request the client sends, retries included, whichever host the URL names.
|
||||
/// </summary>
|
||||
public sealed class CredentialUrlRedactionProcessor : ITelemetryProcessor
|
||||
{
|
||||
private static readonly AsyncLocal<bool> InCredentialRequest = new();
|
||||
|
||||
private readonly ITelemetryProcessor _next;
|
||||
|
||||
public CredentialUrlRedactionProcessor(ITelemetryProcessor next) => _next = next;
|
||||
|
||||
public void Process(ITelemetry item)
|
||||
{
|
||||
if (InCredentialRequest.Value && item is DependencyTelemetry dependency)
|
||||
Redact(dependency);
|
||||
_next.Process(item);
|
||||
}
|
||||
|
||||
internal static void Redact(DependencyTelemetry dependency)
|
||||
{
|
||||
// A row whose URL does not parse keeps nothing of it: the host from Target, else nothing.
|
||||
var origin = Uri.TryCreate(dependency.Data, UriKind.Absolute, out var uri)
|
||||
? $"{uri.Scheme}://{uri.Host}"
|
||||
: dependency.Target ?? string.Empty;
|
||||
dependency.Data = origin;
|
||||
dependency.Name = MethodOf(dependency.Name) is { } method ? $"{method} {origin}" : origin;
|
||||
}
|
||||
|
||||
/// <summary>The HTTP method the collector put in front of the path ("POST /api/webhooks/…").</summary>
|
||||
private static string? MethodOf(string? name)
|
||||
{
|
||||
var space = name?.IndexOf(' ') ?? -1;
|
||||
return space > 0 ? name![..space] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The mark for one credential-bearing HttpClient. The value set here flows into the inner handlers,
|
||||
/// where the collector tracks the dependency, and ends with the request (this override is async, and
|
||||
/// so is HttpClient's own send), so nothing the caller logs or sends afterwards is touched.
|
||||
/// </summary>
|
||||
public sealed class MarkingHandler : DelegatingHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InCredentialRequest.Value = true;
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// A Web Push endpoint is a per-device send capability, so the HttpClient dependency rows the
|
||||
/// worker emits for the push services must not carry it (K20): <c>Data</c> and <c>Name</c> are
|
||||
/// cut down to scheme + host for those hosts. Target, ResultCode and Duration stay, which is all
|
||||
/// <c>/platform-health</c> reads. Everything else passes through untouched.
|
||||
/// </summary>
|
||||
public sealed class PushDependencyRedactionProcessor : ITelemetryProcessor
|
||||
{
|
||||
private static readonly string[] PushHostSuffixes =
|
||||
{
|
||||
".push.apple.com",
|
||||
"fcm.googleapis.com",
|
||||
".push.services.mozilla.com",
|
||||
".notify.windows.com",
|
||||
};
|
||||
|
||||
private readonly ITelemetryProcessor _next;
|
||||
|
||||
public PushDependencyRedactionProcessor(ITelemetryProcessor next) => _next = next;
|
||||
|
||||
public void Process(ITelemetry item)
|
||||
{
|
||||
if (item is DependencyTelemetry dependency)
|
||||
Redact(dependency);
|
||||
_next.Process(item);
|
||||
}
|
||||
|
||||
internal static void Redact(DependencyTelemetry dependency)
|
||||
{
|
||||
if (!Uri.TryCreate(dependency.Data, UriKind.Absolute, out var uri) || !IsPushHost(uri.Host))
|
||||
return;
|
||||
|
||||
var origin = $"{uri.Scheme}://{uri.Host}";
|
||||
dependency.Data = origin;
|
||||
dependency.Name = $"POST {origin}";
|
||||
}
|
||||
|
||||
private static bool IsPushHost(string host)
|
||||
{
|
||||
foreach (var suffix in PushHostSuffixes)
|
||||
{
|
||||
if (suffix.StartsWith('.') ? host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)
|
||||
: host.Equals(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -401,7 +401,7 @@ export interface AnalyzeRuleListResponse {
|
||||
export interface AnalyzerConfiguration {
|
||||
/** Whether to run the LocalAdminAnalyzer at startup and shutdown. Detects pre-enrollment local admin account creation (Autopilot bypass technique). Default: true */
|
||||
enableLocalAdminAnalyzer: boolean;
|
||||
/** Additional local account names considered expected on a newly enrolled device. These are merged (union) with the built-in defaults: Administrator, Guest, DefaultAccount, WDAGUtilityAccount, Public, Default. Any local account not in the merged list will be flagged. Default: empty list (built-in defaults only) */
|
||||
/** The tenant's allowed-list entries, merged with the built-in entries of LocalAdminAllowList: name (glob allowed) in any role, or name|user only while the account is not a member of the local Administrators group. A local account that no entry allows, or that breaks a standard-user entry, is flagged. Default: empty list (built-in entries only) */
|
||||
localAdminAllowedAccounts: string[];
|
||||
/** Whether to run the SoftwareInventoryAnalyzer at startup and shutdown. Collects installed software registry inventory, normalizes entries, and emits a baseline snapshot (startup) plus delta (shutdown) for vulnerability insight. Default: true */
|
||||
enableSoftwareInventoryAnalyzer: boolean;
|
||||
@@ -784,6 +784,8 @@ export interface AuthMeResponse {
|
||||
whatsNewSeenAgentUtc?: string;
|
||||
/** True while the caller's tenant still has to accept the Terms + DPA (its first sign-in skipped the get-started tick); the portal asks before it opens. */
|
||||
dpaAcceptancePending: boolean;
|
||||
/** The company name the tenant's admins entered under Contact, for callers with a tenant role or Global Admins; omitted otherwise or when none is set. Shown in the navbar. */
|
||||
companyName?: string;
|
||||
/** Set while a Global Admin views a tenant as one of its members (read-only "view as"): every other field then describes that member of TenantId. Null on an ordinary sign-in, and the key is omitted. */
|
||||
viewAs?: AuthMeViewAs;
|
||||
}
|
||||
@@ -1141,6 +1143,11 @@ export interface ConfidenceFactor {
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/** POST push/pairings/{pairingId}/confirm — optional body; a blank label keeps the name the device sent. */
|
||||
export interface ConfirmPairingRequest {
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
/** POST push/pairings/{pairingId}/confirm — the device is Active from now on. */
|
||||
export interface ConfirmPairingResponse {
|
||||
deviceId: string;
|
||||
@@ -4219,6 +4226,11 @@ export interface RemoveManagedTenantRequest {
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/** PATCH push/devices/{deviceId} · PATCH global/push/devices/{deviceId} — rename (owner or scope admin); 204. */
|
||||
export interface RenamePushDeviceRequest {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Response of POST rules/reseed-from-github: per-catalog reseed counters. */
|
||||
export interface ReseedFromGitHubResponse {
|
||||
success: boolean;
|
||||
@@ -5247,6 +5259,21 @@ export interface SetUsagePlanRequest {
|
||||
usagePlan?: string | null;
|
||||
}
|
||||
|
||||
/** Response of POST config/{tenantId}/setup-guide: the tenant's setup-guide markers after the write. */
|
||||
export interface SetupGuideStateResponse {
|
||||
success: boolean;
|
||||
settingsReviewedAt?: string;
|
||||
hardwareReviewedAt?: string;
|
||||
dismissedAt?: string;
|
||||
dismissedBy?: string;
|
||||
}
|
||||
|
||||
/** Body of POST config/{tenantId}/setup-guide: the setup-guide step a tenant admin finished. */
|
||||
export interface SetupGuideStepRequest {
|
||||
/** One of settings, hardware, dismiss (hide the guide for the tenant) or restore (show it again). */
|
||||
step?: string | null;
|
||||
}
|
||||
|
||||
/** Distinct-CVE counts grouped by their highest CVSS severity band. */
|
||||
export interface SeverityBreakdown {
|
||||
critical: number;
|
||||
@@ -5732,6 +5759,14 @@ export interface TenantConfiguration {
|
||||
dpaAcceptedBy?: string | null;
|
||||
/** When the Terms + DPA were accepted for this tenant; null when DpaVersion is null. */
|
||||
dpaAcceptedAt?: string | null;
|
||||
/** When a tenant admin finished the setup guide's recommended-settings step, applied or skipped. Written only by the setup-guide endpoint, never through the field patch. */
|
||||
setupGuideSettingsReviewedAt?: string | null;
|
||||
/** When a tenant admin finished the setup guide's hardware step; written like SetupGuideSettingsReviewedAt. */
|
||||
setupGuideHardwareReviewedAt?: string | null;
|
||||
/** When a tenant admin hid the setup guide from the dashboard for the whole tenant; null while it shows. The guide stays reachable from the help menu, which can bring it back. */
|
||||
setupGuideDismissedAt?: string | null;
|
||||
/** UPN of the admin who hid the setup guide; null when SetupGuideDismissedAt is null. */
|
||||
setupGuideDismissedBy?: string | null;
|
||||
/** Address used to reach this tenant about the service itself — a technical problem, a security matter, or a change that needs an administrator's attention. Editable by the tenant's own admins under Settings → Tenant → Contact. Seeded once at onboarding from the tenant's notification address if one was given, and never re-synced afterwards: from that point the value belongs to the tenant, and a later edit must not be overwritten by the onboarding source. Purpose-limited by design — service communication only. It is never used for marketing and never disclosed. Null means we have no way to reach this tenant, which is why enforcement actions cannot promise prior warning. */
|
||||
contactEmail?: string | null;
|
||||
/** Organization name behind this tenant, as the tenant's admins want it read by a support engineer — the counterpart of ContactEmail in the tenant's contact profile. Editable under Settings → Tenant → Contact. Optional on Community. Together with it is required at the self-service Pro entry point (trial start) so a paying tenant is reachable and identifiable for support; it is never a runtime gate on Pro features, and a GA plan assignment does not require it (the admin UI warns instead). Never derived from : the domain is a technical label, the company name is what the tenant tells us. Null means not provided. */
|
||||
@@ -5870,7 +5905,7 @@ export interface TenantConfiguration {
|
||||
showScriptOutput?: boolean | null;
|
||||
/** Whether the LocalAdminAnalyzer is enabled for this tenant's devices. null = use agent default (true). */
|
||||
enableLocalAdminAnalyzer?: boolean | null;
|
||||
/** Whether the SoftwareInventoryAnalyzer is enabled for this tenant's devices. null = use agent default (true). */
|
||||
/** Whether the SoftwareInventoryAnalyzer is enabled for this tenant's devices. null = off (the agent config and the feature flags both read null as false). */
|
||||
enableSoftwareInventoryAnalyzer?: boolean | null;
|
||||
/** Whether the IntegrityBypassAnalyzer is enabled for this tenant's devices. null = use agent default (true). */
|
||||
enableIntegrityBypassAnalyzer?: boolean | null;
|
||||
@@ -5882,7 +5917,7 @@ export interface TenantConfiguration {
|
||||
keepAwakeDuringUserEsp?: boolean | null;
|
||||
/** Whether to detect a SYSTEM console opened during enrollment (Shift+F10 OOBE bypass) for this tenant's devices. Gates the live ConsoleBypass watcher + the startup prefetch scanner. On by default (opt-out); tenants that knowingly use Shift+F10 for support can disable it. null = use agent default (true). */
|
||||
enableConsoleBypassDetection?: boolean | null;
|
||||
/** JSON-serialized list of additional local account names that are considered expected on a newly enrolled device (merged with built-in defaults on the agent). Example: ["SupportAdmin", "TechDesk"] */
|
||||
/** JSON-serialized list of additional local accounts that are expected on a newly enrolled device (merged with the built-in entries on the agent). An entry is an account name or a glob (*, ?), allowed in any role; name|user allows the account only while it is not a member of the local Administrators group. Grammar and built-ins: LocalAdminAllowList. Example: ["SupportAdmin", "adm-*", "kiosk|user"] */
|
||||
localAdminAllowedAccountsJson: string;
|
||||
/** Per-tenant ADDITIVE enable for OOBE Bootstrap Sessions (Global Admin only). The Pro plan includes the feature regardless of this flag; for Community tenants this is the on-request escape hatch. Effective value = plan-included OR this flag — resolved via TenantEntitlementService.IsBootstrapEnabled (backend); when effectively false, the feature is hidden in the UI and all bootstrap API endpoints reject requests. */
|
||||
bootstrapTokenEnabled: boolean;
|
||||
@@ -6003,6 +6038,14 @@ export interface TenantFeatureFlagsResponse {
|
||||
contactEmailSet: boolean;
|
||||
/** Whether a company name is stored (boolean only — the value stays admin-gated). */
|
||||
companyNameSet: boolean;
|
||||
/** Setup guide: the recommended-settings step is done (finished in the guide, or the settings are already on). */
|
||||
setupGuideSettingsDone: boolean;
|
||||
/** Setup guide: the hardware step is done (finished in the guide, or the whitelist differs from the default). */
|
||||
setupGuideHardwareDone: boolean;
|
||||
/** Setup guide: a tenant admin hid it from the dashboard for the whole tenant. */
|
||||
setupGuideDismissed: boolean;
|
||||
/** Whether the tenant ever registered an enrollment session. Omitted when unknown: not read while the setup guide is hidden, or the read failed. The guide shows only on an explicit false. */
|
||||
hasEnrollmentSessions?: boolean;
|
||||
entitlements: TenantFeatureEntitlements;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Shared.DataAccess
|
||||
{
|
||||
@@ -11,7 +12,11 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public interface IDistressReportRepository
|
||||
{
|
||||
Task SaveDistressReportAsync(string tenantId, DistressReportEntry entry);
|
||||
Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, int maxResults = 100);
|
||||
/// <summary>
|
||||
/// The tenant's newest <paramref name="maxResults"/> reports of one type, newest first. The
|
||||
/// type is filtered on the server, so other report types never crowd the window.
|
||||
/// </summary>
|
||||
Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, DistressErrorType errorType, int maxResults = 100);
|
||||
/// <summary>
|
||||
/// The newest <paramref name="maxResults"/> reports across all tenants, newest first.
|
||||
/// Truncated is true when the table held more rows than were returned.
|
||||
|
||||
@@ -81,6 +81,11 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
// --- Tenant Stats (cumulative per-tenant counters, retention-independent) ---
|
||||
/// <summary>Gets the cumulative per-tenant counters, or null if none were recorded yet.</summary>
|
||||
Task<TenantStats?> GetTenantStatsAsync(string tenantId);
|
||||
/// <summary>
|
||||
/// Whether the tenant ever registered an enrollment session (its counter is above zero). No
|
||||
/// counter row is false; a storage error throws, so a caller can tell "never" from "unknown".
|
||||
/// </summary>
|
||||
Task<bool> TenantHasEnrollmentsAsync(string tenantId);
|
||||
/// <summary>Gets the cumulative counters of every tenant that has a row — the source of the platform rollup.</summary>
|
||||
Task<List<TenantStats>> GetAllTenantStatsAsync();
|
||||
/// <summary>Increments a cumulative per-tenant counter (ETag CAS with retries; fail-soft).</summary>
|
||||
|
||||
@@ -627,11 +627,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool EnableLocalAdminAnalyzer { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Additional local account names considered expected on a newly enrolled device.
|
||||
/// These are merged (union) with the built-in defaults:
|
||||
/// Administrator, Guest, DefaultAccount, WDAGUtilityAccount, Public, Default.
|
||||
/// Any local account not in the merged list will be flagged.
|
||||
/// Default: empty list (built-in defaults only)
|
||||
/// The tenant's allowed-list entries, merged with the built-in entries of
|
||||
/// <see cref="LocalAdminAllowList"/>: <c>name</c> (glob allowed) in any role, or <c>name|user</c>
|
||||
/// only while the account is not a member of the local Administrators group. A local account
|
||||
/// that no entry allows, or that breaks a standard-user entry, is flagged.
|
||||
/// Default: empty list (built-in entries only)
|
||||
/// </summary>
|
||||
public List<string> LocalAdminAllowedAccounts { get; set; } = new List<string>();
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>What an allowed-list entry lets a local account be.</summary>
|
||||
public enum LocalAccountRole
|
||||
{
|
||||
/// <summary>Any role, local administrator included — an entry without qualifier.</summary>
|
||||
Any = 0,
|
||||
|
||||
/// <summary>Only while the account is not a direct member of the local Administrators group (<c>name|user</c>).</summary>
|
||||
StandardUser = 1,
|
||||
}
|
||||
|
||||
/// <summary>What a built-in allowed-list entry is matched against.</summary>
|
||||
public enum LocalAdminBuiltInMatch
|
||||
{
|
||||
/// <summary>The well-known RID of a built-in account on the machine SID — the same in every language.</summary>
|
||||
Rid = 0,
|
||||
|
||||
/// <summary>An account name; the C:\Users folder of the same name is allowed with it.</summary>
|
||||
AccountName = 1,
|
||||
|
||||
/// <summary>A C:\Users folder that is never a local account.</summary>
|
||||
ProfileFolder = 2,
|
||||
}
|
||||
|
||||
/// <summary>One built-in entry of the Local Admin Analyzer's allowed list.</summary>
|
||||
public sealed class LocalAdminBuiltInEntry
|
||||
{
|
||||
public LocalAdminBuiltInEntry(string name, LocalAdminBuiltInMatch match, LocalAccountRole role, int? rid = null)
|
||||
{
|
||||
Name = name;
|
||||
Match = match;
|
||||
Role = role;
|
||||
Rid = rid;
|
||||
}
|
||||
|
||||
/// <summary>Account or folder name; for a RID entry the English name of the account.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
public LocalAdminBuiltInMatch Match { get; }
|
||||
|
||||
public LocalAccountRole Role { get; }
|
||||
|
||||
/// <summary>The well-known RID of a RID entry, otherwise null.</summary>
|
||||
public int? Rid { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Local Admin Analyzer's allowed list: the built-in entries and the grammar of the tenant's
|
||||
/// entries (<see cref="TenantConfiguration.LocalAdminAllowedAccountsJson"/>). Single source for the
|
||||
/// agent's evaluation, the backend's write validation and the portal (shared manifest).
|
||||
/// <para>
|
||||
/// A tenant entry is an account name or a glob (<c>*</c>, <c>?</c>), optionally followed by
|
||||
/// <c>|user</c>: the account is then allowed only while it is not a member of the local
|
||||
/// Administrators group. Windows account names cannot contain '|', so the separator is unambiguous,
|
||||
/// and an agent that predates it matches such an entry against no account (fail-safe).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class LocalAdminAllowList
|
||||
{
|
||||
public const char RoleSeparator = '|';
|
||||
|
||||
public const string StandardUserQualifier = "user";
|
||||
|
||||
public const int MaxEntries = 200;
|
||||
|
||||
public const int MaxEntryLength = 64;
|
||||
|
||||
/// <summary>A glob needs this many characters besides <c>*</c> and <c>?</c> — a bare <c>*</c> would switch the analyzer off.</summary>
|
||||
public const int MinPatternLiterals = 2;
|
||||
|
||||
/// <summary>Characters Windows rejects in account names, apart from the separator and the glob characters.</summary>
|
||||
public const string ForbiddenNameCharacters = "\"/\\[]:;=,+<>";
|
||||
|
||||
/// <summary>
|
||||
/// The built-in entries. The four built-in accounts are decided by their RID alone: no name
|
||||
/// entry, built-in or tenant, can lift "Guest is never an administrator" or flag the real
|
||||
/// Administrator. The name entries cover OTHER accounts carrying a built-in name — a decoy without
|
||||
/// admin rights is fine, an administrator is not.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LocalAdminBuiltInEntry> BuiltIns { get; } = new LocalAdminBuiltInEntry[]
|
||||
{
|
||||
new("Administrator", LocalAdminBuiltInMatch.Rid, LocalAccountRole.Any, 500),
|
||||
new("Guest", LocalAdminBuiltInMatch.Rid, LocalAccountRole.StandardUser, 501),
|
||||
new("DefaultAccount", LocalAdminBuiltInMatch.Rid, LocalAccountRole.StandardUser, 503),
|
||||
new("WDAGUtilityAccount", LocalAdminBuiltInMatch.Rid, LocalAccountRole.StandardUser, 504),
|
||||
|
||||
new("Administrator", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
new("Guest", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
new("DefaultAccount", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
new("WDAGUtilityAccount", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
|
||||
// The OOBE account is an Administrators member during the ESP.
|
||||
new("defaultuser0", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.Any),
|
||||
new("defaultuser1", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.Any),
|
||||
new("defaultuser2", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.Any),
|
||||
|
||||
// Assigned Access autologon account: a local standard user by design.
|
||||
new("kioskUser0", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
|
||||
// Web sign-in's system-managed account, enabled for each web sign-in.
|
||||
new("WsiAccount", LocalAdminBuiltInMatch.AccountName, LocalAccountRole.StandardUser),
|
||||
|
||||
new("Public", LocalAdminBuiltInMatch.ProfileFolder, LocalAccountRole.Any),
|
||||
new("Default", LocalAdminBuiltInMatch.ProfileFolder, LocalAccountRole.Any),
|
||||
new("Default User", LocalAdminBuiltInMatch.ProfileFolder, LocalAccountRole.Any),
|
||||
new("All Users", LocalAdminBuiltInMatch.ProfileFolder, LocalAccountRole.Any),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses a tenant entry. An entry without <see cref="RoleSeparator"/> is the pattern verbatim
|
||||
/// (role Any) — exactly what it meant before qualifiers existed. With the separator, only
|
||||
/// <c>pattern|user</c> parses (qualifier case-insensitive, no surrounding spaces); anything else
|
||||
/// returns false and must allow nothing.
|
||||
/// </summary>
|
||||
public static bool TryParseEntry(string? raw, [NotNullWhen(true)] out string? pattern, out LocalAccountRole role)
|
||||
{
|
||||
pattern = null;
|
||||
role = LocalAccountRole.Any;
|
||||
if (raw is null || string.IsNullOrWhiteSpace(raw))
|
||||
return false;
|
||||
|
||||
var separator = raw.IndexOf(RoleSeparator);
|
||||
if (separator < 0)
|
||||
{
|
||||
pattern = raw;
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = raw.Substring(0, separator);
|
||||
var qualifier = raw.Substring(separator + 1);
|
||||
if (name.Length == 0 || name.Trim().Length != name.Length
|
||||
|| !string.Equals(qualifier, StandardUserQualifier, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
pattern = name;
|
||||
role = LocalAccountRole.StandardUser;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The canonical entry text: the pattern, plus <c>|user</c> for a standard-user entry.</summary>
|
||||
public static string Format(string pattern, LocalAccountRole role)
|
||||
=> role == LocalAccountRole.StandardUser ? pattern + RoleSeparator + StandardUserQualifier : pattern;
|
||||
|
||||
/// <summary>True for a glob with fewer than <see cref="MinPatternLiterals"/> characters besides <c>*</c> and <c>?</c>.</summary>
|
||||
public static bool IsTooBroad(string pattern)
|
||||
{
|
||||
if (pattern.IndexOf('*') < 0 && pattern.IndexOf('?') < 0)
|
||||
return false;
|
||||
|
||||
var literals = 0;
|
||||
foreach (var ch in pattern)
|
||||
{
|
||||
if (ch != '*' && ch != '?')
|
||||
literals++;
|
||||
}
|
||||
return literals < MinPatternLiterals;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks an entry a caller is about to ADD. Returns a user-facing reason, or null when the entry
|
||||
/// may be stored. Stored entries are never re-checked: they keep their meaning even where they
|
||||
/// would not pass today.
|
||||
/// </summary>
|
||||
public static string? ValidateNewEntry(string? raw)
|
||||
{
|
||||
if (raw is null || string.IsNullOrWhiteSpace(raw))
|
||||
return "entries must not be empty.";
|
||||
if (raw.Length > MaxEntryLength)
|
||||
return $"each entry must be at most {MaxEntryLength} characters.";
|
||||
foreach (var ch in raw)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
return "entries must not contain control characters.";
|
||||
}
|
||||
if (raw.Trim().Length != raw.Length)
|
||||
return $"\"{raw}\" must not start or end with a space.";
|
||||
if (!TryParseEntry(raw, out var pattern, out _))
|
||||
return $"\"{raw}\" is not valid: use an account name or pattern, optionally followed by \"{RoleSeparator}{StandardUserQualifier}\" (standard user only).";
|
||||
if (pattern.IndexOfAny(ForbiddenNameCharacters.ToCharArray()) >= 0)
|
||||
return $"\"{raw}\" contains a character account names cannot have ({string.Join(" ", ToStrings(ForbiddenNameCharacters))}).";
|
||||
if (LooksLikeSid(pattern))
|
||||
return $"\"{raw}\" is a SID — enter the account name instead.";
|
||||
if (IsTooBroad(pattern))
|
||||
return $"\"{raw}\" is too broad: a pattern needs at least {MinPatternLiterals} characters besides * and ?.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool LooksLikeSid(string pattern)
|
||||
=> pattern.Length > 4
|
||||
&& pattern.StartsWith("S-1-", StringComparison.OrdinalIgnoreCase)
|
||||
&& char.IsDigit(pattern[4]);
|
||||
|
||||
private static IEnumerable<string> ToStrings(string characters)
|
||||
{
|
||||
foreach (var ch in characters)
|
||||
yield return ch.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,24 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>When the Terms + DPA were accepted for this tenant; null when <see cref="DpaVersion"/> is null.</summary>
|
||||
public DateTime? DpaAcceptedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When a tenant admin finished the setup guide's recommended-settings step, applied or
|
||||
/// skipped. Written only by the setup-guide endpoint, never through the field patch.
|
||||
/// </summary>
|
||||
public DateTime? SetupGuideSettingsReviewedAt { get; set; }
|
||||
|
||||
/// <summary>When a tenant admin finished the setup guide's hardware step; written like <see cref="SetupGuideSettingsReviewedAt"/>.</summary>
|
||||
public DateTime? SetupGuideHardwareReviewedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When a tenant admin hid the setup guide from the dashboard for the whole tenant; null
|
||||
/// while it shows. The guide stays reachable from the help menu, which can bring it back.
|
||||
/// </summary>
|
||||
public DateTime? SetupGuideDismissedAt { get; set; }
|
||||
|
||||
/// <summary>UPN of the admin who hid the setup guide; null when <see cref="SetupGuideDismissedAt"/> is null.</summary>
|
||||
public string? SetupGuideDismissedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Address used to reach this tenant about the service itself — a technical problem,
|
||||
/// a security matter, or a change that needs an administrator's attention. Editable by
|
||||
@@ -637,7 +655,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>
|
||||
/// Whether the SoftwareInventoryAnalyzer is enabled for this tenant's devices.
|
||||
/// null = use agent default (true).
|
||||
/// null = off (the agent config and the feature flags both read null as false).
|
||||
/// </summary>
|
||||
public bool? EnableSoftwareInventoryAnalyzer { get; set; } = null;
|
||||
|
||||
@@ -678,14 +696,17 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool? EnableConsoleBypassDetection { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serialized list of additional local account names that are considered expected
|
||||
/// on a newly enrolled device (merged with built-in defaults on the agent).
|
||||
/// Example: ["SupportAdmin", "TechDesk"]
|
||||
/// JSON-serialized list of additional local accounts that are expected on a newly enrolled
|
||||
/// device (merged with the built-in entries on the agent). An entry is an account name or a
|
||||
/// glob (<c>*</c>, <c>?</c>), allowed in any role; <c>name|user</c> allows the account only
|
||||
/// while it is not a member of the local Administrators group. Grammar and built-ins:
|
||||
/// <see cref="LocalAdminAllowList"/>.
|
||||
/// Example: ["SupportAdmin", "adm-*", "kiosk|user"]
|
||||
/// </summary>
|
||||
public string LocalAdminAllowedAccountsJson { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the deserialized list of additional allowed local admin account names.
|
||||
/// Returns the deserialized list of additional allowed local account entries.
|
||||
/// </summary>
|
||||
public List<string> GetLocalAdminAllowedAccounts()
|
||||
{
|
||||
|
||||
@@ -47,6 +47,17 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool ContactEmailSet { get; set; }
|
||||
/// <summary>Whether a company name is stored (boolean only — the value stays admin-gated).</summary>
|
||||
public bool CompanyNameSet { get; set; }
|
||||
/// <summary>Setup guide: the recommended-settings step is done (finished in the guide, or the settings are already on).</summary>
|
||||
public bool SetupGuideSettingsDone { get; set; }
|
||||
/// <summary>Setup guide: the hardware step is done (finished in the guide, or the whitelist differs from the default).</summary>
|
||||
public bool SetupGuideHardwareDone { get; set; }
|
||||
/// <summary>Setup guide: a tenant admin hid it from the dashboard for the whole tenant.</summary>
|
||||
public bool SetupGuideDismissed { get; set; }
|
||||
/// <summary>
|
||||
/// Whether the tenant ever registered an enrollment session. Omitted when unknown: not read
|
||||
/// while the setup guide is hidden, or the read failed. The guide shows only on an explicit false.
|
||||
/// </summary>
|
||||
public bool? HasEnrollmentSessions { get; set; }
|
||||
public TenantFeatureEntitlements Entitlements { get; set; } = default!;
|
||||
}
|
||||
|
||||
|
||||
@@ -270,6 +270,28 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string EffectiveEdition { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body of POST config/{tenantId}/setup-guide: the setup-guide step a tenant admin finished.
|
||||
/// </summary>
|
||||
public class SetupGuideStepRequest : IApiRequest
|
||||
{
|
||||
/// <summary>One of <c>settings</c>, <c>hardware</c>, <c>dismiss</c> (hide the guide for the tenant) or <c>restore</c> (show it again).</summary>
|
||||
public string? Step { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of POST config/{tenantId}/setup-guide: the tenant's setup-guide markers after the write.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class SetupGuideStateResponse : IApiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public DateTime? SettingsReviewedAt { get; set; }
|
||||
public DateTime? HardwareReviewedAt { get; set; }
|
||||
public DateTime? DismissedAt { get; set; }
|
||||
public string? DismissedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of GET and PUT global/config/plan-tiers: the global usage-plan tier definitions plus the
|
||||
/// built-in catalog values every unset definition field falls back to.
|
||||
|
||||
@@ -49,6 +49,9 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>True while the caller's tenant still has to accept the Terms + DPA (its first sign-in skipped the get-started tick); the portal asks before it opens.</summary>
|
||||
public bool DpaAcceptancePending { get; set; }
|
||||
|
||||
/// <summary>The company name the tenant's admins entered under Contact, for callers with a tenant role or Global Admins; omitted otherwise or when none is set. Shown in the navbar.</summary>
|
||||
public string? CompanyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set while a Global Admin views a tenant as one of its members (read-only "view as"): every other field
|
||||
/// then describes that member of <see cref="TenantId"/>. Null on an ordinary sign-in, and the key is omitted.
|
||||
|
||||
@@ -34,6 +34,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public DateTime RedeemedUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST push/pairings/{pairingId}/confirm — optional body; a blank label keeps the name the device sent.</summary>
|
||||
public class ConfirmPairingRequest : IApiRequest
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST push/pairings/{pairingId}/confirm — the device is Active from now on.</summary>
|
||||
public class ConfirmPairingResponse : IApiResponse
|
||||
{
|
||||
@@ -141,6 +147,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string Scope { get; set; } = "tenant";
|
||||
}
|
||||
|
||||
/// <summary>PATCH push/devices/{deviceId} · PATCH global/push/devices/{deviceId} — rename (owner or scope admin); 204.</summary>
|
||||
public class RenamePushDeviceRequest : IApiRequest
|
||||
{
|
||||
public string Label { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>GET/PUT/DELETE sessions/{sessionId}/watch — "notify my devices when this session ends".</summary>
|
||||
public class SessionWatchResponse : IApiResponse
|
||||
{
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ const SERVER_DENIED_FIELDS = new Set([
|
||||
"tenantId", "domainName", "partitionKey", "rowKey", "timestamp", "eTag",
|
||||
"lastUpdated", "updatedBy", "onboardedAt", "onboardedBy", "dpaVersion",
|
||||
"dpaAcceptancePending", "dpaAcceptedBy", "dpaAcceptedAt",
|
||||
"setupGuideSettingsReviewedAt", "setupGuideHardwareReviewedAt", "setupGuideDismissedAt", "setupGuideDismissedBy",
|
||||
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
|
||||
"graphAccessRejectedSince", "graphAccessRejectedCode", "graphAccessRejectionConfirmedAt",
|
||||
"intuneDeviceBindingPermissionMissingSince", "intuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
|
||||
@@ -60,8 +60,7 @@ export function SectionAlerts() {
|
||||
/>
|
||||
{/* Platform Push devices (plan push-relay K4), once the config is loaded: the SAVED ops channel
|
||||
list says whether the channel is on — opsNotificationChannels mirrors the stored config, the
|
||||
editor above keeps its own draft. With the channel off the panel shows only while devices
|
||||
are left to remove. */}
|
||||
editor above keeps its own draft. Without the channel the panel says which channel to add. */}
|
||||
{adminConfig != null && (
|
||||
<PushDevicesPanel scope="platform" channelEnabled={hasEnabledPushChannel(opsNotificationChannels)} />
|
||||
)}
|
||||
|
||||
+6
-2
@@ -106,6 +106,10 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
|
||||
dpaAcceptancePending: { section: "Tenant Status", label: "DPA Acceptance Pending", informational: true },
|
||||
dpaAcceptedBy: { section: "Tenant Status", label: "DPA Accepted By", informational: true },
|
||||
dpaAcceptedAt: { section: "Tenant Status", label: "DPA Accepted At", kind: "date", informational: true },
|
||||
setupGuideSettingsReviewedAt: { section: "Tenant Status", label: "Setup Guide: Settings Reviewed At", kind: "date", informational: true },
|
||||
setupGuideHardwareReviewedAt: { section: "Tenant Status", label: "Setup Guide: Hardware Reviewed At", kind: "date", informational: true },
|
||||
setupGuideDismissedAt: { section: "Tenant Status", label: "Setup Guide Hidden At", kind: "date", informational: true },
|
||||
setupGuideDismissedBy: { section: "Tenant Status", label: "Setup Guide Hidden By", informational: true },
|
||||
contactEmail: { section: "Tenant Status", label: "Contact Email", informational: true },
|
||||
companyName: { section: "Tenant Status", label: "Company", informational: true },
|
||||
|
||||
@@ -192,7 +196,7 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
|
||||
|
||||
// Analyzers
|
||||
enableLocalAdminAnalyzer: { section: "Analyzers", label: "Local Admin Analyzer" },
|
||||
localAdminAllowedAccountsJson: { section: "Analyzers", label: "Allowed Local Admin Accounts" },
|
||||
localAdminAllowedAccountsJson: { section: "Analyzers", label: "Allowed Local Accounts" },
|
||||
enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer" },
|
||||
enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer" },
|
||||
enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection" },
|
||||
@@ -345,7 +349,7 @@ export const COLLECTOR_FIELDS: Record<keyof CollectorConfiguration, RuntimeRow |
|
||||
|
||||
export const ANALYZER_FIELDS: Record<keyof AnalyzerConfiguration, RuntimeRow | Excluded> = {
|
||||
enableLocalAdminAnalyzer: { section: "Analyzers", label: "Local Admin Analyzer", source: "tenant" },
|
||||
localAdminAllowedAccounts: { section: "Analyzers", label: "Allowed Local Admin Accounts", source: "tenant" },
|
||||
localAdminAllowedAccounts: { section: "Analyzers", label: "Allowed Local 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" },
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useContinueSetup, type SetupChecklistState } from "@/hooks/useSetupChecklist";
|
||||
import { api } from "@/lib/api";
|
||||
import { apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import type {
|
||||
DeviceNotRegisteredItem,
|
||||
DeviceNotRegisteredResponse,
|
||||
HardwareRejectedItem,
|
||||
HardwareRejectedResponse,
|
||||
TenantConfiguration,
|
||||
} from "@/lib/generated/wire-types.generated";
|
||||
import { addWhitelistEntry, reportedValueEntry } from "@/lib/hardwareWhitelist";
|
||||
import { route } from "@/lib/routes";
|
||||
import { SETUP_STEPS, type SetupStepId } from "@/lib/setupGuide";
|
||||
import { markSetup, openSetupGuide } from "@/lib/setupGuideStore";
|
||||
import { recordSetupGuideStep, writeGuideConfigFields } from "@/lib/setupGuideWrites";
|
||||
|
||||
const ROWS: Record<SetupStepId, { title: string; required: boolean; open: string; done: string; cta: string }> = {
|
||||
settings: {
|
||||
title: "Apply recommended settings",
|
||||
required: false,
|
||||
open: "Timezone, diagnostics package, software inventory.",
|
||||
done: "Recommended settings reviewed.",
|
||||
cta: "Review",
|
||||
},
|
||||
hardware: {
|
||||
title: "Check your hardware",
|
||||
required: false,
|
||||
open: "Dell, HP, Lenovo and Microsoft are accepted. Using other brands or virtual machines?",
|
||||
done: "Hardware whitelist checked.",
|
||||
cta: "Check",
|
||||
},
|
||||
validation: {
|
||||
title: "Turn on device validation",
|
||||
required: true,
|
||||
open: "Agent data is rejected until one validation method is on.",
|
||||
done: "Device validation is on.",
|
||||
cta: "Enable",
|
||||
},
|
||||
deploy: {
|
||||
title: "Deploy the agent with Intune",
|
||||
required: true,
|
||||
open: "One platform script for your Autopilot devices. The agent installs on first boot.",
|
||||
done: "First enrollment received.",
|
||||
cta: "Deploy",
|
||||
},
|
||||
};
|
||||
|
||||
const pillRecommended = "px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-600 border border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600";
|
||||
const pillRequired = "px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-900/30 dark:text-amber-200 dark:border-amber-800";
|
||||
const ctaClass = "inline-flex items-center gap-1 px-2 py-1.5 rounded-md text-sm font-medium text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-900/20";
|
||||
|
||||
/**
|
||||
* The dashboard's "Get started" checklist for a tenant that never enrolled a device. It replaces
|
||||
* the welcome card and, while it shows, the red "no device validation" banner: the required step
|
||||
* says the same thing without alarming a tenant on its first day. Done states come from the flags.
|
||||
*/
|
||||
export function SetupChecklistCard({ setup }: { setup: SetupChecklistState }) {
|
||||
const { getAccessToken } = useAuth();
|
||||
const continueSetup = useContinueSetup();
|
||||
const [hiding, setHiding] = useState(false);
|
||||
const { checklist, canEdit, readOnlyReason, tenantId } = setup;
|
||||
if (!checklist) return null;
|
||||
|
||||
const waiting = checklist.next === "deploy";
|
||||
// Agents can connect as soon as a validation is on, whatever the recommended steps say: from
|
||||
// then on the card shows which devices the backend turned away.
|
||||
const devicesCanConnect = checklist.done.validation && !checklist.done.deploy;
|
||||
|
||||
const hide = async () => {
|
||||
setHiding(true);
|
||||
try {
|
||||
await recordSetupGuideStep(tenantId, "dismiss", getAccessToken);
|
||||
markSetup(tenantId, { dismissed: true });
|
||||
trackEvent("setup_guide_dismissed", { done: checklist.doneCount });
|
||||
} catch {
|
||||
setHiding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="setup-checklist-title" className="mb-6 bg-white shadow rounded-lg overflow-hidden dark:bg-gray-800">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 px-6 pt-5 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="min-w-0 flex-1 basis-72">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-green-700 dark:text-green-400">Get started</p>
|
||||
<h2 id="setup-checklist-title" className="mt-1 text-xl font-semibold text-gray-900 dark:text-gray-100">Set up Autopilot Monitor</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Four steps until your first enrollment shows up here. The guide walks you through each one; the last one happens in Intune.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:flex-col sm:items-end sm:gap-1.5">
|
||||
<span className="text-xs text-gray-500 tabular-nums dark:text-gray-400">{checklist.doneCount} of 4 done</span>
|
||||
<span className="block w-36 h-1.5 rounded-full bg-gray-100 overflow-hidden dark:bg-gray-700" aria-hidden="true">
|
||||
<span className="block h-full rounded-full bg-green-600 transition-[width] duration-500" style={{ width: `${checklist.doneCount * 25}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol className="px-3 py-2">
|
||||
{SETUP_STEPS.map((step) => {
|
||||
const row = ROWS[step];
|
||||
const done = checklist.done[step];
|
||||
const isNext = checklist.next === step;
|
||||
return (
|
||||
<li key={step} className={`grid grid-cols-[1.5rem_minmax(0,1fr)] sm:grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 px-3 py-2.5 rounded-md ${isNext ? "bg-green-50/70 dark:bg-green-900/10" : ""}`}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center ${done ? "bg-green-600 text-white" : isNext ? "border-2 border-green-600" : "border-2 border-gray-300 dark:border-gray-600"}`}
|
||||
>
|
||||
{done && <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className={`flex flex-wrap items-center gap-2 text-sm font-medium ${done ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}>
|
||||
{row.title}
|
||||
<span className={row.required ? pillRequired : pillRecommended}>{row.required ? "Required" : "Recommended"}</span>
|
||||
<span className="sr-only">{done ? "(done)" : "(open)"}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{done ? row.done : row.open}</p>
|
||||
</div>
|
||||
<div className="col-start-2 sm:col-start-auto">
|
||||
{done ? (
|
||||
<span className="inline-flex items-center gap-1 pr-2 text-xs font-medium text-green-700 dark:text-green-400">Done</span>
|
||||
) : canEdit || readOnlyReason === "view-as" ? (
|
||||
<button type="button" className={ctaClass} onClick={() => continueSetup(step, "card")}>
|
||||
{row.cta}
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{devicesCanConnect && <WaitingDetails canEdit={canEdit} tenantId={tenantId} />}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-6 py-3.5 border-t border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
{waiting ? (
|
||||
<>
|
||||
<span className="relative flex h-2.5 w-2.5" aria-hidden="true">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-60 motion-reduce:animate-none" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-green-600" />
|
||||
</span>
|
||||
<p className="flex-1 basis-64 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span className="font-semibold">Waiting for your first enrollment.</span>{" "}
|
||||
This page updates as soon as a device starts enrolling.
|
||||
</p>
|
||||
<button type="button" className={ctaClass} onClick={() => continueSetup("deploy", "card")}>Deployment steps</button>
|
||||
</>
|
||||
) : readOnlyReason === "role" ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">A tenant admin completes these steps.</p>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => continueSetup(checklist.next, "card")}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-green-600 hover:bg-green-700 ${checklist.doneCount === 0 ? "motion-safe:animate-pulse" : ""}`}
|
||||
>
|
||||
{checklist.doneCount === 0 ? "Start guided setup" : "Continue setup"}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={hiding}
|
||||
onClick={() => void hide()}
|
||||
title="You can open the setup guide again from the help menu"
|
||||
className="text-sm font-medium text-gray-500 hover:text-gray-700 hover:underline underline-offset-2 disabled:opacity-50 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
{readOnlyReason === "view-as" && <span className="text-xs text-gray-500 dark:text-gray-400">View as is read-only.</span>}
|
||||
<span className="ml-auto text-xs text-gray-400">About 10 minutes, Intune included</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
/** What the portal can see while it waits: why a device may not show up, and devices the backend turned away. */
|
||||
function WaitingDetails({ canEdit, tenantId }: { canEdit: boolean; tenantId: string }) {
|
||||
const { getAccessToken } = useAuth();
|
||||
const [hardware, setHardware] = useState<HardwareRejectedItem[]>([]);
|
||||
const [unregistered, setUnregistered] = useState<DeviceNotRegisteredItem[]>([]);
|
||||
const [accepted, setAccepted] = useState<ReadonlySet<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const [hw, nr] = await Promise.all([
|
||||
fetchJson<HardwareRejectedResponse>(api.distress.hardwareRejected(), getAccessToken),
|
||||
fetchJson<DeviceNotRegisteredResponse>(api.distress.deviceNotRegistered(), getAccessToken),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setHardware(hw.aggregated ?? []);
|
||||
setUnregistered(nr.aggregated ?? []);
|
||||
} catch {
|
||||
// The list is a hint; without it the checklist above still says what to check.
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const timer = window.setInterval(() => void load(), REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [getAccessToken]);
|
||||
|
||||
// `entry` comes from reportedValueEntry: the reported manufacturer as one literal pattern.
|
||||
const accept = async (entry: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const config = await fetchJson<TenantConfiguration>(api.config.tenant(tenantId), getAccessToken);
|
||||
const next = addWhitelistEntry(config.manufacturerWhitelist ?? "", entry);
|
||||
await writeGuideConfigFields(tenantId, { manufacturerWhitelist: next }, "setup-guide:accept-hardware", getAccessToken);
|
||||
markSetup(tenantId, { hardware: true });
|
||||
setAccepted((prev) => new Set(prev).add(entry));
|
||||
trackEvent("setup_hardware_accepted");
|
||||
} catch (err) {
|
||||
setError(apiErrorText(err, "The manufacturer could not be added."));
|
||||
}
|
||||
};
|
||||
|
||||
const autopilotPage: Route = route("/settings/tenant/autopilot");
|
||||
const shownHardware = hardware.slice(0, 3);
|
||||
const shownUnregistered = unregistered.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="px-6 pb-3 space-y-3">
|
||||
{(shownHardware.length > 0 || shownUnregistered.length > 0) && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 space-y-2 dark:border-amber-800 dark:bg-amber-900/20">
|
||||
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">Devices we turned away</p>
|
||||
<ul className="space-y-1.5 text-sm text-gray-700 dark:text-gray-200">
|
||||
{shownHardware.map((item) => {
|
||||
// Reported values arrive unauthenticated: a wildcard in them never becomes an entry.
|
||||
const entry = reportedValueEntry(item.manufacturer);
|
||||
return (
|
||||
<li key={`hw-${item.manufacturer}-${item.model}`} className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="min-w-0 flex-1 basis-60">
|
||||
<span className="font-mono text-xs">{item.manufacturer} · {item.model}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400"> — manufacturer not on your hardware whitelist</span>
|
||||
</span>
|
||||
{entry && accepted.has(entry) ? (
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">
|
||||
Added <span className="font-mono">{entry}</span>: its next enrollments are accepted
|
||||
</span>
|
||||
) : entry && canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void accept(entry)}
|
||||
title={`Adds "${entry}" to the manufacturer whitelist`}
|
||||
className="px-2.5 py-1 rounded-md border border-amber-300 text-xs font-medium text-amber-900 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/40"
|
||||
>
|
||||
Accept this manufacturer
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{shownUnregistered.map((item) => (
|
||||
<li key={`nr-${item.serialNumber}`} className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="min-w-0 flex-1 basis-60">
|
||||
<span className="font-mono text-xs">{item.serialNumber}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400"> — not registered as an Autopilot device in this tenant</span>
|
||||
</span>
|
||||
<Link href={autopilotPage} className="text-xs font-medium text-amber-900 hover:underline dark:text-amber-100">Autopilot Validation</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
<details className="group rounded-lg border border-gray-200 px-4 py-2.5 text-sm dark:border-gray-700">
|
||||
<summary className="cursor-pointer font-medium text-gray-700 dark:text-gray-200">No device after an hour? Check these</summary>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-gray-600 dark:text-gray-300">
|
||||
<li>The platform script is assigned to a group your test device is in.</li>
|
||||
<li>The test device is fresh: reset or new, and switched on for less than 12 hours.</li>
|
||||
<li>Virtual machines report their own manufacturer. It has to be on the hardware whitelist.</li>
|
||||
<li>
|
||||
The dry run on the device shows why the agent skipped it:{" "}
|
||||
<a href={`${DOCS_URL}${DOCS_PATHS.deployTheAgent}`} target="_blank" rel="noopener noreferrer" className="font-medium text-green-700 hover:underline dark:text-green-400">
|
||||
Deploy the agent
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The empty dashboard of a tenant without the checklist: the guide is hidden, or the caller has no tenant role. */
|
||||
export function NoSessionsNote({ canOpenGuide }: { canOpenGuide: boolean }) {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg px-6 py-5 dark:bg-gray-800">
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100">No enrollments yet</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Sessions appear here in real time once the agent runs on a device that enrolls.{" "}
|
||||
<a href={`${DOCS_URL}${DOCS_PATHS.deployTheAgent}`} target="_blank" rel="noopener noreferrer" className="font-medium text-green-700 hover:underline dark:text-green-400">
|
||||
Deploy the agent
|
||||
</a>
|
||||
{canOpenGuide && (
|
||||
<>
|
||||
{" "}or{" "}
|
||||
<button type="button" onClick={() => openSetupGuide("empty_note")} className="font-medium text-green-700 hover:underline dark:text-green-400">
|
||||
open the setup guide
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown once, when the first session of a tenant arrives while the checklist is on screen. */
|
||||
export function FirstEnrollmentCard({ deviceLabel, href, onDismiss }: { deviceLabel: string; href: Route; onDismiss: () => void }) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4 rounded-lg border border-green-200 bg-green-50 px-5 py-4 dark:border-green-800 dark:bg-green-900/20">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-green-600 text-white" aria-hidden="true">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 basis-64">
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100">Your first enrollment is here</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{deviceLabel} started enrolling. Setup is complete; the setup guide stays in the help menu.</p>
|
||||
</div>
|
||||
<Link href={href} onClick={() => trackEvent("setup_first_session_opened")} className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-green-600 hover:bg-green-700">
|
||||
Open session
|
||||
</Link>
|
||||
<button type="button" onClick={onDismiss} aria-label="Dismiss" className="p-1 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import { GITHUB_REPO_URL } from "@/lib/githubStars";
|
||||
|
||||
// The card shows only while the tenant has no sessions, so these clicks are the onboarding funnel
|
||||
// between signup and the first enrollment. All links open a new tab; the page stays loaded.
|
||||
const trackLink = (link: "setup_guide" | "deploy_agent" | "docs" | "github") =>
|
||||
trackEvent("welcome_link_clicked", { link });
|
||||
|
||||
export function WelcomeMessage() {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{/* Top accent bar */}
|
||||
<div className="h-1 bg-gradient-to-r from-green-500 via-emerald-500 to-teal-500" />
|
||||
<div className="p-6 sm:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="shrink-0 w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 leading-tight">Welcome to Autopilot Monitor</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">Your tenant is connected — deploy the agent to start monitoring enrollments.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<ol className="space-y-3 mb-7">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">1</span>
|
||||
<p className="text-sm text-gray-700">
|
||||
Follow the{" "}
|
||||
<a href={`${DOCS_URL}${DOCS_PATHS.portalSetup}`} target="_blank" rel="noopener noreferrer" onClick={() => trackLink("setup_guide")} className="text-green-700 hover:underline font-medium">setup guide</a>
|
||||
{" "}to configure your tenant and grant the required permissions.
|
||||
</p>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">2</span>
|
||||
<p className="text-sm text-gray-700">
|
||||
Deploy the{" "}
|
||||
<a href={`${DOCS_URL}${DOCS_PATHS.deployTheAgent}`} target="_blank" rel="noopener noreferrer" onClick={() => trackLink("deploy_agent")} className="text-green-700 hover:underline font-medium">Intune bootstrapper</a>
|
||||
{" "}to your Autopilot device groups — the agent installs automatically on first boot.
|
||||
</p>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-green-600 text-white text-[10px] font-bold flex items-center justify-center">3</span>
|
||||
<p className="text-sm text-gray-700">Enrollment sessions will appear here in real time as devices go through the enrollment process.</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<a
|
||||
href={DOCS_URL} target="_blank" rel="noopener noreferrer"
|
||||
onClick={() => trackLink("docs")}
|
||||
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-green-300 hover:bg-green-50 transition-colors"
|
||||
>
|
||||
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-green-200 flex items-center justify-center shadow-sm">
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Documentation</p>
|
||||
<p className="text-xs text-gray-500">Setup and configuration guides</p>
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-gray-300 group-hover:text-green-400 ml-auto shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackLink("github")}
|
||||
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 hover:border-gray-400 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="shrink-0 w-8 h-8 rounded-lg bg-white border border-gray-200 group-hover:border-gray-300 flex items-center justify-center shadow-sm">
|
||||
<svg className="w-4 h-4 text-gray-700" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">GitHub Repository</p>
|
||||
<p className="text-xs text-gray-500">Source code and issue tracking</p>
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-gray-300 group-hover:text-gray-500 ml-auto shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { WelcomeMessage } from "../WelcomeMessage";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import { GITHUB_REPO_URL } from "@/lib/githubStars";
|
||||
|
||||
describe("WelcomeMessage", () => {
|
||||
it("links the setup guide, the deploy guide, the docs and the repository in a new tab", () => {
|
||||
const html = renderToStaticMarkup(createElement(WelcomeMessage));
|
||||
for (const href of [
|
||||
`${DOCS_URL}${DOCS_PATHS.portalSetup}`,
|
||||
`${DOCS_URL}${DOCS_PATHS.deployTheAgent}`,
|
||||
DOCS_URL,
|
||||
GITHUB_REPO_URL,
|
||||
]) {
|
||||
expect(html).toContain(`href="${href}"`);
|
||||
}
|
||||
expect(html.match(/target="_blank"/g)).toHaveLength(4);
|
||||
});
|
||||
|
||||
// The growth report reads welcome_link_clicked as the step between signup and the first
|
||||
// enrollment; a link without its own id would drop out of that funnel unnoticed.
|
||||
it("tracks every link with its own id", () => {
|
||||
const source = readFileSync(join(__dirname, "..", "WelcomeMessage.tsx"), "utf-8");
|
||||
const ids = [...source.matchAll(/trackLink\("([a-z_]+)"\)/g)].map(m => m[1]);
|
||||
expect(source.match(/<a\b/g)).toHaveLength(ids.length);
|
||||
expect(ids).toEqual(["setup_guide", "deploy_agent", "docs", "github"]);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../contexts/NotificationContext";
|
||||
import { StatsCard } from "./components/StatsCards";
|
||||
import { WelcomeMessage } from "./components/WelcomeMessage";
|
||||
import { FirstEnrollmentCard, NoSessionsNote, SetupChecklistCard } from "./components/SetupChecklistCard";
|
||||
import { ActivelyDevelopedBanner } from "./components/ActivelyDevelopedBanner";
|
||||
import { AppHomingBanner } from "./components/AppHomingBanner";
|
||||
import { GraphAccessBanner } from "./components/GraphAccessBanner";
|
||||
@@ -21,6 +21,8 @@ import { TenantFilterBar } from "./components/TenantFilterBar";
|
||||
import { DeleteConfirmModal, BlockConfirmModal } from "./components/ConfirmationModals";
|
||||
import TipOfTheDay from "./components/TipOfTheDay";
|
||||
import { useAdminMode } from "@/hooks/useAdminMode";
|
||||
import { useSetupChecklist } from "@/hooks/useSetupChecklist";
|
||||
import { markSetup } from "@/lib/setupGuideStore";
|
||||
import { useDeleteSession } from "./hooks/useDeleteSession";
|
||||
import { useBlockDevice } from "./hooks/useBlockDevice";
|
||||
import { useTenantSecurityConfig } from "./hooks/useTenantSecurityConfig";
|
||||
@@ -264,6 +266,34 @@ function HomeContent() {
|
||||
graphAccessRejected, graphAccessAppMissing, addOnPermissionMissing, addOnPermissionMissingBlocksAll,
|
||||
} = useTenantSecurityConfig(tenantId, user, getAccessToken, notifyError);
|
||||
|
||||
// A tenant that never enrolled a device gets the setup checklist instead of an empty page. While
|
||||
// the checklist is on, its required validation step stands in for the red validation banner; the
|
||||
// banner waits for the checklist's flags, so it does not flash before the card replaces it.
|
||||
const setup = useSetupChecklist();
|
||||
const setupOwnsEmptyState = !crossTenant && !!setup.checklist?.visible;
|
||||
const showSetupCard = setupOwnsEmptyState && !loading && sessions.length === 0;
|
||||
|
||||
// The first session arriving while the checklist was on screen gets a one-time success card.
|
||||
// Adjust-during-render (compare-prev), keyed on the tenant so a view-as switch never reads as an arrival.
|
||||
// A cross-tenant view forgets the card: on the way back the cross-tenant list is still loaded for one render.
|
||||
const [setupCardSeenFor, setSetupCardSeenFor] = useState<string | null>(null);
|
||||
const [firstArrival, setFirstArrival] = useState<{ tenantId: string; sessionId: string; label: string; dismissed: boolean } | null>(null);
|
||||
if (showSetupCard && setupCardSeenFor !== tenantId) {
|
||||
setSetupCardSeenFor(tenantId);
|
||||
}
|
||||
if (crossTenant && setupCardSeenFor !== null) {
|
||||
setSetupCardSeenFor(null);
|
||||
}
|
||||
if (!crossTenant && setupCardSeenFor === tenantId && firstArrival?.tenantId !== tenantId && sessions.length > 0) {
|
||||
const first = sessions[0];
|
||||
setFirstArrival({ tenantId, sessionId: first.sessionId, label: first.deviceName || first.serialNumber || "A device", dismissed: false });
|
||||
}
|
||||
const arrivedTenant = firstArrival?.tenantId ?? null;
|
||||
useEffect(() => {
|
||||
if (arrivedTenant) markSetup(arrivedTenant, { enrolled: true });
|
||||
}, [arrivedTenant]);
|
||||
const showFirstArrival = !crossTenant && firstArrival !== null && firstArrival.tenantId === tenantId && !firstArrival.dismissed;
|
||||
|
||||
// Disable global-scope mode for users without platform scope. A read-only Global Reader keeps it
|
||||
// (their cross-tenant view is read-only-safe; writes are gated separately + backend-enforced).
|
||||
useEffect(() => {
|
||||
@@ -373,7 +403,17 @@ function HomeContent() {
|
||||
<AppHomingBanner />
|
||||
)}
|
||||
|
||||
{deviceValidationEnabled === false && (
|
||||
{firstArrival && showFirstArrival && (
|
||||
<FirstEnrollmentCard
|
||||
deviceLabel={firstArrival.label}
|
||||
href={sessionUrl(firstArrival.sessionId)}
|
||||
onDismiss={() => setFirstArrival({ ...firstArrival, dismissed: true })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSetupCard && <SetupChecklistCard setup={setup} />}
|
||||
|
||||
{deviceValidationEnabled === false && !setup.pending && !setupOwnsEmptyState && (
|
||||
<div className="mb-6 bg-red-600 border-2 border-red-700 rounded-xl p-5 shadow-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -516,8 +556,10 @@ function HomeContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Welcome message - only show when no sessions */}
|
||||
{!loading && sessions.length === 0 && <WelcomeMessage />}
|
||||
{/* Own tenant, no sessions, no checklist: the guide is hidden or the caller has no tenant role. */}
|
||||
{!loading && sessions.length === 0 && !crossTenant && !setupOwnsEmptyState && !setup.pending && (
|
||||
<NoSessionsNote canOpenGuide={!!(user?.isTenantAdmin || user?.isGlobalAdmin)} />
|
||||
)}
|
||||
|
||||
{/* Fleet-context rule filter chip (from a session-detail "View sessions" link). */}
|
||||
{ruleFilter && (
|
||||
|
||||
@@ -40,6 +40,17 @@ html {
|
||||
--view-as-offset: var(--view-as-bar-height);
|
||||
}
|
||||
|
||||
/* ===== Pinned save bar (app/settings/components/SaveBar.tsx) =====
|
||||
While a settings card has unsaved edits, its Save bar sticks to the bottom of the viewport. The
|
||||
floating buttons at the bottom right (scroll to top, feedback) rise by --save-bar-offset, the bar's
|
||||
height while one is pinned and 0 otherwise, so they never cover Save. */
|
||||
:root {
|
||||
--save-bar-offset: 0px;
|
||||
}
|
||||
:root:has([data-save-bar-pinned]) {
|
||||
--save-bar-offset: 4.5rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #f9fafb;
|
||||
--foreground: #171717;
|
||||
@@ -997,3 +1008,22 @@ html.auth-pending .landing-v2::after {
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.3s ease-out both;
|
||||
}
|
||||
|
||||
/* Setup guide spotlight: the dashed ring around the highlighted setting marches slowly. */
|
||||
@keyframes setup-tour-march {
|
||||
to { stroke-dashoffset: -28; }
|
||||
}
|
||||
.setup-tour-ring {
|
||||
animation: setup-tour-march 1.1s linear infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.setup-tour-ring { animation: none; }
|
||||
}
|
||||
/* The dimming around the cut-out: one wide shadow. Stronger on the dark theme, where a slate tint
|
||||
leaves light text almost as bright as before. */
|
||||
.setup-tour-spot {
|
||||
box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
.dark .setup-tour-spot {
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import ScrollToTopButton from "../components/ScrollToTopButton";
|
||||
import FeedbackBubble from "../components/FeedbackBubble";
|
||||
import { WhatsNewPanelHost } from "../components/WhatsNewPanelHost";
|
||||
import { FeedbackDialogHost } from "../components/FeedbackDialogHost";
|
||||
import { SetupGuideHost } from "../components/setup/SetupGuideHost";
|
||||
import { SidebarProvider } from "../contexts/SidebarContext";
|
||||
import { GlobalSidebar } from "../components/GlobalSidebar";
|
||||
import AppInsightsInit from "../components/AppInsightsInit";
|
||||
@@ -216,6 +217,7 @@ export default function RootLayout({
|
||||
<FeedbackBubble />
|
||||
<WhatsNewPanelHost />
|
||||
<FeedbackDialogHost />
|
||||
<SetupGuideHost />
|
||||
</SidebarProvider>
|
||||
</TenantProvider>
|
||||
</TenantNotificationProvider>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
|
||||
const LAST_UPDATED = "4 November 2026";
|
||||
const LAST_UPDATED = "9 November 2026";
|
||||
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
|
||||
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
|
||||
const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`;
|
||||
@@ -178,9 +178,9 @@ export default function PrivacyPage() {
|
||||
<p className="text-gray-700">
|
||||
Diagnostics upload is <strong>off by default</strong>. When enabled, the default destination is{" "}
|
||||
<strong>your own Azure storage account</strong> — the package never reaches our infrastructure. Hosted upload
|
||||
exists as an alternative but is opt-in only and requires an explicit administrator action — in Settings, or
|
||||
in the one-step dialog offered on a session — always behind a clearly marked disclosure that data leaves your
|
||||
tenant. It is never enabled silently. One explicit exception: when an administrator submits a session report
|
||||
exists as an alternative. It takes an explicit administrator action to turn it on, and in the portal that
|
||||
action always comes with a clearly marked disclosure that data leaves your tenant. It is never enabled
|
||||
silently. One explicit exception: when an administrator submits a session report
|
||||
for analysis and chooses to include the session's uploaded diagnostics archive, a copy of that package
|
||||
is stored with the report so it remains available for the investigation.
|
||||
</p>
|
||||
|
||||
@@ -423,6 +423,12 @@ export default function PushPairPage() {
|
||||
{isAndroid && !standalone && (
|
||||
<p className="text-xs text-gray-500">Adding this page to the Home screen is recommended: Android keeps notification permission for installed apps.</p>
|
||||
)}
|
||||
{/* Desktop browsers may answer the request with only a small address-bar icon instead of a prompt. */}
|
||||
{!isIos && !isAndroid && permission === "default" && (
|
||||
<p className="text-xs text-gray-500">
|
||||
No prompt from the browser? On a computer it may show only a small icon in the address bar. Click it and choose Allow.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveCollectButtonState,
|
||||
} from "./collectLogsLogic";
|
||||
import { ModalPortal } from "@/components/ModalPortal";
|
||||
import { HostedUploadDisclosure } from "@/components/HostedUploadDisclosure";
|
||||
|
||||
interface CollectLogsButtonProps {
|
||||
sessionId: string;
|
||||
@@ -268,20 +269,7 @@ export default function CollectLogsButton({
|
||||
{" "}<span className="font-semibold">hosted storage</span> (managed by Autopilot Monitor) with mode
|
||||
{" "}<span className="font-semibold">On failure</span>, then collects the logs from this device right away.
|
||||
</p>
|
||||
{/* Data-boundary disclosure. This dialog is the SECOND opt-in path into hosted
|
||||
upload (the first is Settings → Diagnostics Package), and the privacy page and
|
||||
trust FAQ both promise that hosted upload is only ever enabled behind a clearly
|
||||
marked "data leaves your tenant" disclosure — this block is that promise here. */}
|
||||
<div className="bg-sky-50 border border-sky-200 rounded-lg p-3 space-y-1">
|
||||
<p className="text-sm text-sky-900">
|
||||
<span className="font-semibold">Uploaded packages leave your own Azure tenant boundary.</span>{" "}
|
||||
They are stored in the Autopilot Monitor backend's Azure Storage (Germany West Central),
|
||||
isolated per tenant, and each upload uses a fresh blob-scoped, write-only token.
|
||||
</p>
|
||||
<p className="text-xs text-sky-800">
|
||||
Packages are removed automatically according to your <span className="font-semibold">Data Retention Days</span> setting.
|
||||
</p>
|
||||
</div>
|
||||
<HostedUploadDisclosure />
|
||||
<p className="text-sm text-gray-600">
|
||||
You can change the destination (e.g. to your own Azure Blob Storage) or turn uploads off again at any
|
||||
time under Settings → Diagnostics Package, which also lists exactly which paths are collected.
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { TourSpotlight } from "@/components/setup/TourSpotlight";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useTenant } from "@/contexts/TenantContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { fetchJson } from "@/lib/apiClient";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import type { AutopilotConsentUrlResponse } from "@/lib/generated/wire-types.generated";
|
||||
import { SETUP_TOUR_TARGETS, type SetupTour } from "@/lib/setupGuide";
|
||||
import { endSetupTour, markSetup, openSetupGuide, useSetupTour } from "@/lib/setupGuideStore";
|
||||
import { useTenantConfig } from "./TenantConfigContext";
|
||||
|
||||
const trimSlash = (path: string) => (path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path);
|
||||
|
||||
/**
|
||||
* The setup guide's spotlight on a settings page. The tour sits in sessionStorage, so it survives
|
||||
* the device-validation consent, which leaves the portal and comes back to the validation page.
|
||||
*/
|
||||
export function SetupGuideTour() {
|
||||
const tour = useSetupTour();
|
||||
const pathname = usePathname();
|
||||
if (!tour || trimSlash(pathname) !== trimSlash(SETUP_TOUR_TARGETS[tour].path)) return null;
|
||||
return tour === "validation" ? <ValidationTour /> : <ShowMeTour tour={tour} />;
|
||||
}
|
||||
|
||||
const eyebrow = "flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-green-700 dark:text-green-400";
|
||||
const primaryButton = "inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const secondaryButton = "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700";
|
||||
const linkButton = "text-sm font-medium text-green-700 hover:underline underline-offset-2 disabled:opacity-50 dark:text-green-400";
|
||||
|
||||
function CloseButton() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={endSetupTour}
|
||||
aria-label="Close setup guide"
|
||||
className="ml-auto -my-1 -mr-1 p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const SHOW_ME: Record<Exclude<SetupTour, "validation">, { anchors: readonly string[]; title: string; crumb: string; text: string }> = {
|
||||
"showme:timezone": {
|
||||
anchors: ["timezone", "geo-location"],
|
||||
title: "Set Timezone Automatically",
|
||||
crumb: "Agent › Agent Settings › Agent Parameters",
|
||||
text: "The guide turns it on if you keep it checked. You can switch it here at any time. It appears once Geo-Location Detection is on.",
|
||||
},
|
||||
"showme:diagnostics": {
|
||||
anchors: ["diagnostics-upload"],
|
||||
title: "Upload destination",
|
||||
crumb: "Agent › Diagnostics Package",
|
||||
text: "The guide picks hosted storage here, or keeps your own storage account. Upload Mode, further down on this page, sets when a package is uploaded. You can change both at any time.",
|
||||
},
|
||||
"showme:inventory": {
|
||||
anchors: ["software-inventory"],
|
||||
title: "Software Inventory & Vulnerability Analyzer",
|
||||
crumb: "Agent › Agent Analyzers",
|
||||
text: "The guide turns it on. Its findings show up in each session and under Software.",
|
||||
},
|
||||
};
|
||||
|
||||
function ShowMeTour({ tour }: { tour: Exclude<SetupTour, "validation"> }) {
|
||||
const text = SHOW_ME[tour];
|
||||
const back = () => {
|
||||
endSetupTour();
|
||||
openSetupGuide("showme", "settings");
|
||||
};
|
||||
return (
|
||||
<TourSpotlight anchors={text.anchors} label="Setup guide hint" onClose={endSetupTour}>
|
||||
<div className="space-y-2">
|
||||
<p className={eyebrow}>Setup guide · Where to find it<CloseButton /></p>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">{text.title}</h3>
|
||||
<p className="text-xs text-gray-400">{text.crumb}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{text.text}</p>
|
||||
<div className="pt-1">
|
||||
<button type="button" className={primaryButton} onClick={back}>Back to the guide</button>
|
||||
</div>
|
||||
</div>
|
||||
</TourSpotlight>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidationTour() {
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const { tenantId } = useTenant();
|
||||
const {
|
||||
canEditConfig, error, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
|
||||
validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice, validateIntuneDeviceBinding,
|
||||
} = useTenantConfig();
|
||||
const [target, setTarget] = useState<"autopilot" | "preparation">("autopilot");
|
||||
const [planB, setPlanB] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyFailed, setCopyFailed] = useState(false);
|
||||
|
||||
const anyGate = validateAutopilotDevice || validateCorporateIdentifier || validateDeviceAssociation
|
||||
|| validateCloudPcDevice || validateIntuneDeviceBinding;
|
||||
const readOnly = !!user?.viewAs || !canEditConfig;
|
||||
const failed = !!error && !anyGate && !autopilotConsentInProgress;
|
||||
|
||||
useEffect(() => {
|
||||
if (anyGate && tenantId) markSetup(tenantId, { validation: true });
|
||||
}, [anyGate, tenantId]);
|
||||
|
||||
const close = useCallback(() => endSetupTour(), []);
|
||||
|
||||
// Only the Windows Autopilot bubble approves from here; with device preparation the admin picks
|
||||
// one of the two options in the highlighted block, each with its own switch and access check.
|
||||
const approve = () => {
|
||||
trackEvent("setup_consent_started");
|
||||
void beginDeviceValidationConsentFlow("autopilot");
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
setCopyFailed(false);
|
||||
try {
|
||||
const data = await fetchJson<AutopilotConsentUrlResponse>(
|
||||
api.config.autopilotConsentUrl(tenantId, `${window.location.origin}${SETUP_TOUR_TARGETS.validation.path}`), getAccessToken);
|
||||
await navigator.clipboard.writeText(data.consentUrl);
|
||||
setCopied(true);
|
||||
trackEvent("setup_approval_link_copied");
|
||||
} catch {
|
||||
setCopyFailed(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (anyGate) {
|
||||
return (
|
||||
<TourSpotlight anchors={["autopilot-validation"]} label="Setup guide: validation is on" done onClose={close}>
|
||||
<div className="space-y-2">
|
||||
<p className={eyebrow}>Setup guide · Step 3 done<CloseButton /></p>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">Validation is on</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Your tenant now accepts data from its validated devices. One step is left: put the agent on them with one Intune script.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<button type="button" className={primaryButton} onClick={() => { endSetupTour(); openSetupGuide("tour", "deploy"); }}>
|
||||
Next: deploy the agent
|
||||
</button>
|
||||
<button type="button" className="text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400" onClick={close}>Later</button>
|
||||
</div>
|
||||
</div>
|
||||
</TourSpotlight>
|
||||
);
|
||||
}
|
||||
|
||||
if (autopilotConsentInProgress) {
|
||||
return (
|
||||
<TourSpotlight anchors={["autopilot-validation"]} label="Setup guide: checking the approval" onClose={close}>
|
||||
<div className="space-y-2">
|
||||
<p className={eyebrow}>Setup guide · Step 3 of 4<CloseButton /></p>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">Checking the approval…</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">Microsoft can take up to a minute to confirm it. This page updates on its own.</p>
|
||||
</div>
|
||||
</TourSpotlight>
|
||||
);
|
||||
}
|
||||
|
||||
const anchors = target === "autopilot" ? ["autopilot-validation"] : ["device-preparation-validation"];
|
||||
return (
|
||||
<TourSpotlight anchors={anchors} label="Setup guide step 3" onClose={close}>
|
||||
<div className="space-y-2.5">
|
||||
<p className={eyebrow}>
|
||||
Setup guide · Step 3 of 4
|
||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-medium normal-case tracking-normal bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-900/30 dark:text-amber-200 dark:border-amber-800">Required</span>
|
||||
<CloseButton />
|
||||
</p>
|
||||
{failed && (
|
||||
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
|
||||
The approval was not completed. If you can't approve it yourself, send the link below to someone who can.
|
||||
</p>
|
||||
)}
|
||||
{target === "autopilot" ? (
|
||||
<>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">Turn on Autopilot device validation</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
The backend only accepts data from devices registered as Windows Autopilot devices in your tenant. Almost every tenant uses this check.
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li><span className="font-semibold text-gray-800 dark:text-gray-100">What happens:</span> Microsoft asks you to approve one read-only permission. Then you are back on this page and the setting is saved.</li>
|
||||
<li><span className="font-semibold text-gray-800 dark:text-gray-100">Who can approve:</span> a Global Administrator or Privileged Role Administrator.</li>
|
||||
</ul>
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<button type="button" className={primaryButton} disabled={readOnly} onClick={approve}>Turn on and approve</button>
|
||||
<button type="button" className={linkButton} onClick={() => setTarget("preparation")}>I use device preparation</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">Using Autopilot device preparation?</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Turn on Corporate Identifier Validation or Device Association Validation, whichever list you keep in Intune. Both use the same
|
||||
read-only permission and the same approval.
|
||||
</p>
|
||||
<button type="button" className={linkButton} onClick={() => setTarget("autopilot")}>Back to Windows Autopilot</button>
|
||||
</>
|
||||
)}
|
||||
<div className="border-t border-gray-200 pt-2 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={planB || failed}
|
||||
onClick={() => setPlanB(!planB)}
|
||||
className="text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-200"
|
||||
>
|
||||
Can't approve it yourself?
|
||||
</button>
|
||||
{(planB || failed) && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Send the approval link to your Global Administrator. Once they approved it, click{" "}
|
||||
<span className="font-semibold">Detect existing access</span>
|
||||
{target === "autopilot" ? "." : " under the validation you use."}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className={secondaryButton} disabled={readOnly} onClick={() => void copyLink()}>
|
||||
{copied ? "Link copied" : "Copy approval link"}
|
||||
</button>
|
||||
{target === "autopilot" && (
|
||||
<button type="button" className={secondaryButton} disabled={readOnly} onClick={() => void detectExistingAccess("autopilot")}>
|
||||
Detect existing access
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{copyFailed && <p className="text-xs text-red-600 dark:text-red-400">The link could not be copied. Try again.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{readOnly && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{user?.viewAs ? "View as is read-only." : "Only a tenant admin can turn this on."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TourSpotlight>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type SetStateAction } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
@@ -28,10 +28,21 @@ const VALIDATION_ENABLED_SUFFIX = " enabled. Backend agent endpoints are now unl
|
||||
const ACCESS_RESTORED_MESSAGE = "Access restored. The notice on the dashboard can take a few minutes to disappear.";
|
||||
import { parseSasExpiry } from "./components/DiagnosticsSection";
|
||||
import { COMMUNITY_DEFAULT, parseEditionInfo, type EditionInfo } from "@/lib/edition";
|
||||
import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath, NotificationChannel, LEGACY_CHANNEL_ID } from "./types";
|
||||
import { TenantConfiguration, TenantAdmin } from "./types";
|
||||
import { SECTION_FIELD_MAP, type SectionFieldSpec, type SettingsSectionName } from "./sectionFieldMap";
|
||||
import { HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS } from "./lib/helloWaitTimeout";
|
||||
import {
|
||||
DEFAULT_SETTINGS_FORM,
|
||||
configFromForm,
|
||||
formFromConfig,
|
||||
hardwareRejectionNotify,
|
||||
sectionsWithUnsavedEdits,
|
||||
settingsFormSetters,
|
||||
type SettingsForm,
|
||||
type SettingsFormSetters,
|
||||
} from "./lib/settingsForm";
|
||||
import { changedTenantConfigFields, patchTenantConfigFields } from "@/lib/tenantConfigSave";
|
||||
import { subscribeTenantConfigPatches } from "@/lib/tenantConfigPatches";
|
||||
import { leaveOrAsk } from "@/lib/unsavedChanges";
|
||||
import { looksLikeGuid, type MemberKind } from "@/lib/principalKeys";
|
||||
import { type BootstrapSessionItem } from "./components/BootstrapSessionsSection";
|
||||
import type {
|
||||
@@ -48,39 +59,6 @@ import type {
|
||||
UpdateMemberPermissionsRequest,
|
||||
} from "@/lib/generated/wire-types.generated";
|
||||
|
||||
/**
|
||||
* Channels for display/editing from a loaded config: prefers notificationChannelsJson; while
|
||||
* unset, synthesizes ONE channel from the legacy single-webhook fields under the stable
|
||||
* "legacy" id (mirrors backend TenantConfiguration.GetNotificationChannels so the UI shows
|
||||
* exactly what the backend dispatches). First save materializes the synthesized channel.
|
||||
*/
|
||||
function channelsFromConfig(data: TenantConfiguration): NotificationChannel[] {
|
||||
if (data.notificationChannelsJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(data.notificationChannelsJson);
|
||||
if (Array.isArray(parsed)) return parsed as NotificationChannel[];
|
||||
} catch {
|
||||
// Malformed (hand-edited) JSON — fall through to legacy synthesis.
|
||||
}
|
||||
}
|
||||
if (data.webhookUrl && data.webhookProviderType) {
|
||||
return [{
|
||||
id: LEGACY_CHANNEL_ID,
|
||||
name: "Default",
|
||||
providerType: data.webhookProviderType,
|
||||
url: data.webhookUrl,
|
||||
customHeadersJson: data.webhookCustomHeadersJson || undefined,
|
||||
enabled: true,
|
||||
notifyOnStart: data.webhookNotifyOnStart ?? false,
|
||||
notifyOnSuccess: data.webhookNotifyOnSuccess ?? true,
|
||||
notifyOnFailure: data.webhookNotifyOnFailure ?? true,
|
||||
notifyOnHardwareRejection: data.webhookNotifyOnHardwareRejection ?? false,
|
||||
notifyOnSlaEvents: true, // legacy behavior: SLA alerts always went to the single webhook
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context value interface
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -96,7 +74,8 @@ export interface OffboardingInProgressInfo {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface TenantConfigContextValue {
|
||||
interface TenantConfigContextValue
|
||||
extends SettingsForm, Omit<SettingsFormSetters, "setWebhookNotifyOnHardwareRejection"> {
|
||||
// Core
|
||||
config: TenantConfiguration | null;
|
||||
loading: boolean;
|
||||
@@ -108,6 +87,8 @@ interface TenantConfigContextValue {
|
||||
*/
|
||||
canEditConfig: boolean;
|
||||
savingSection: string | null;
|
||||
/** Sections whose Save would write an edit. Never a section that saves on change; always empty without write access. */
|
||||
unsavedSections: ReadonlySet<SettingsSectionName>;
|
||||
error: string | null;
|
||||
setError: (e: string | null) => void;
|
||||
successMessage: string | null;
|
||||
@@ -136,17 +117,6 @@ interface TenantConfigContextValue {
|
||||
graphAccessAppMissing: boolean;
|
||||
|
||||
// Validation
|
||||
validateAutopilotDevice: boolean;
|
||||
setValidateAutopilotDevice: (v: boolean) => void;
|
||||
validateCorporateIdentifier: boolean;
|
||||
setValidateCorporateIdentifier: (v: boolean) => void;
|
||||
validateDeviceAssociation: boolean;
|
||||
setValidateDeviceAssociation: (v: boolean) => void;
|
||||
/** Toggle + persist DevPrep Device Association validation in one shot (no consent flow needed). */
|
||||
validateCloudPcDevice: boolean;
|
||||
setValidateCloudPcDevice: (v: boolean) => void;
|
||||
validateIntuneDeviceBinding: boolean;
|
||||
setValidateIntuneDeviceBinding: (v: boolean) => void;
|
||||
/**
|
||||
* Toggle + persist W365 Cloud PC validation in one shot. No consent flow — the backing
|
||||
* CloudPC.Read.All permission is granted via the Optional Graph capabilities add-on script.
|
||||
@@ -172,123 +142,35 @@ interface TenantConfigContextValue {
|
||||
detectExistingAccess: (trigger: ConsentTrigger) => Promise<void>;
|
||||
|
||||
// Hardware whitelist
|
||||
manufacturerWhitelist: string;
|
||||
setManufacturerWhitelist: (v: string) => void;
|
||||
modelWhitelist: string;
|
||||
setModelWhitelist: (v: string) => void;
|
||||
/** What the "notify on rejection" switch shows (hardwareRejectionNotify). */
|
||||
webhookNotifyOnHardwareRejection: boolean;
|
||||
/** With channels configured, writes the flag through to every channel. */
|
||||
setWebhookNotifyOnHardwareRejection: (v: boolean) => void;
|
||||
handleSaveHardwareWhitelist: () => Promise<void>;
|
||||
|
||||
// Agent settings
|
||||
enablePerformanceCollector: boolean;
|
||||
setEnablePerformanceCollector: (v: boolean) => void;
|
||||
performanceCollectorInterval: number;
|
||||
setPerformanceCollectorInterval: (v: number) => void;
|
||||
helloWaitTimeoutSeconds: number;
|
||||
setHelloWaitTimeoutSeconds: (v: number) => void;
|
||||
selfDestructOnComplete: boolean;
|
||||
setSelfDestructOnComplete: (v: boolean) => void;
|
||||
keepLogFile: boolean;
|
||||
setKeepLogFile: (v: boolean) => void;
|
||||
rebootOnComplete: boolean;
|
||||
setRebootOnComplete: (v: boolean) => void;
|
||||
rebootDelaySeconds: number;
|
||||
setRebootDelaySeconds: (v: number) => void;
|
||||
contactEmail: string;
|
||||
setContactEmail: (v: string) => void;
|
||||
companyName: string;
|
||||
setCompanyName: (v: string) => void;
|
||||
// Agent settings and contact
|
||||
handleSaveContact: () => Promise<void>;
|
||||
enableGeoLocation: boolean;
|
||||
setEnableGeoLocation: (v: boolean) => void;
|
||||
enableTimezoneAutoSet: boolean;
|
||||
setEnableTimezoneAutoSet: (v: boolean) => void;
|
||||
enableDoGroupIdAutoSet: boolean;
|
||||
setEnableDoGroupIdAutoSet: (v: boolean) => void;
|
||||
keepAwakeDuringUserEsp: boolean;
|
||||
setKeepAwakeDuringUserEsp: (v: boolean) => void;
|
||||
enableImeMatchLog: boolean;
|
||||
setEnableImeMatchLog: (v: boolean) => void;
|
||||
enableGatherRuleDebugLog: boolean;
|
||||
setEnableGatherRuleDebugLog: (v: boolean) => void;
|
||||
logLevel: string;
|
||||
setLogLevel: (v: string) => void;
|
||||
showScriptOutput: boolean;
|
||||
setShowScriptOutput: (v: boolean) => void;
|
||||
showEnrollmentSummary: boolean;
|
||||
setShowEnrollmentSummary: (v: boolean) => void;
|
||||
enrollmentSummaryTimeoutSeconds: number;
|
||||
setEnrollmentSummaryTimeoutSeconds: (v: number) => void;
|
||||
enrollmentSummaryBrandingImageUrl: string;
|
||||
setEnrollmentSummaryBrandingImageUrl: (v: string) => void;
|
||||
enrollmentSummaryLaunchRetrySeconds: number;
|
||||
setEnrollmentSummaryLaunchRetrySeconds: (v: number) => void;
|
||||
enableRealmJoinWatcher: boolean;
|
||||
setEnableRealmJoinWatcher: (v: boolean) => void;
|
||||
enableConfigMgrWatcher: boolean;
|
||||
setEnableConfigMgrWatcher: (v: boolean) => void;
|
||||
handleSaveAgentParameters: () => Promise<void>;
|
||||
handleSaveAgentCollectors: () => Promise<void>;
|
||||
|
||||
// Agent analyzers
|
||||
enableLocalAdminAnalyzer: boolean;
|
||||
setEnableLocalAdminAnalyzer: (v: boolean) => void;
|
||||
localAdminAllowedAccounts: string[];
|
||||
setLocalAdminAllowedAccounts: (v: string[]) => void;
|
||||
newAllowedAccount: string;
|
||||
setNewAllowedAccount: (v: string) => void;
|
||||
enableSoftwareInventoryAnalyzer: boolean;
|
||||
setEnableSoftwareInventoryAnalyzer: (v: boolean) => void;
|
||||
enableIntegrityBypassAnalyzer: boolean;
|
||||
setEnableIntegrityBypassAnalyzer: (v: boolean) => void;
|
||||
enableConsoleBypassDetection: boolean;
|
||||
setEnableConsoleBypassDetection: (v: boolean) => void;
|
||||
handleSaveAgentAnalyzers: () => Promise<void>;
|
||||
|
||||
// Unrestricted mode
|
||||
unrestrictedMode: boolean;
|
||||
setUnrestrictedMode: (v: boolean) => void;
|
||||
handleSaveUnrestrictedMode: (value: boolean) => Promise<boolean>;
|
||||
|
||||
// Notifications / channels
|
||||
notificationChannels: NotificationChannel[];
|
||||
setNotificationChannels: (v: SetStateAction<NotificationChannel[]>) => void;
|
||||
testingChannelId: string | null;
|
||||
testChannelResult: { channelId: string; success: boolean; message: string } | null;
|
||||
handleTestChannel: (channelId: string) => Promise<void>;
|
||||
handleSaveNotifications: () => Promise<void>;
|
||||
|
||||
// SLA Targets
|
||||
slaTargetSuccessRate: number | null;
|
||||
setSlaTargetSuccessRate: (v: number | null) => void;
|
||||
slaTargetMaxDurationMinutes: number | null;
|
||||
setSlaTargetMaxDurationMinutes: (v: number | null) => void;
|
||||
slaTargetAppInstallSuccessRate: number | null;
|
||||
setSlaTargetAppInstallSuccessRate: (v: number | null) => void;
|
||||
slaNotifyOnSuccessRateBreach: boolean;
|
||||
setSlaNotifyOnSuccessRateBreach: (v: boolean) => void;
|
||||
slaSuccessRateNotifyThreshold: number | null;
|
||||
setSlaSuccessRateNotifyThreshold: (v: number | null) => void;
|
||||
slaNotifyOnDurationBreach: boolean;
|
||||
setSlaNotifyOnDurationBreach: (v: boolean) => void;
|
||||
slaNotifyOnAppInstallBreach: boolean;
|
||||
setSlaNotifyOnAppInstallBreach: (v: boolean) => void;
|
||||
slaNotifyOnConsecutiveFailures: boolean;
|
||||
setSlaNotifyOnConsecutiveFailures: (v: boolean) => void;
|
||||
slaConsecutiveFailureThreshold: number;
|
||||
setSlaConsecutiveFailureThreshold: (v: number) => void;
|
||||
handleSaveSlaTargets: () => Promise<void>;
|
||||
|
||||
// Diagnostics
|
||||
diagnosticsBlobSasUrl: string;
|
||||
setDiagnosticsBlobSasUrl: (v: string) => void;
|
||||
diagnosticsUploadMode: string;
|
||||
setDiagnosticsUploadMode: (v: string) => void;
|
||||
diagnosticsUploadDestination: string;
|
||||
setDiagnosticsUploadDestination: (v: string) => void;
|
||||
tenantDiagPaths: DiagnosticsLogPath[];
|
||||
setTenantDiagPaths: (v: DiagnosticsLogPath[]) => void;
|
||||
newDiagPath: string;
|
||||
setNewDiagPath: (v: string) => void;
|
||||
newDiagDesc: string;
|
||||
@@ -321,10 +203,6 @@ interface TenantConfigContextValue {
|
||||
revokeBootstrapSession: (code: string) => Promise<void>;
|
||||
|
||||
// Data management
|
||||
dataRetentionDays: number;
|
||||
setDataRetentionDays: (v: number) => void;
|
||||
sessionTimeoutHours: number;
|
||||
setSessionTimeoutHours: (v: number) => void;
|
||||
handleSaveDataManagement: () => Promise<void>;
|
||||
|
||||
// Offboarding
|
||||
@@ -350,6 +228,8 @@ interface TenantConfigContextValue {
|
||||
|
||||
const TenantConfigContext = createContext<TenantConfigContextValue | null>(null);
|
||||
|
||||
const NO_UNSAVED_SECTIONS: ReadonlySet<SettingsSectionName> = new Set();
|
||||
|
||||
export function useTenantConfig() {
|
||||
const ctx = useContext(TenantConfigContext);
|
||||
if (!ctx) throw new Error("useTenantConfig must be used within TenantConfigProvider");
|
||||
@@ -396,82 +276,19 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
const [bootstrapSessions, setBootstrapSessions] = useState<BootstrapSessionItem[]>([]);
|
||||
const [bootstrapLoading, setBootstrapLoading] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [manufacturerWhitelist, setManufacturerWhitelist] = useState("Dell*,HP*,Lenovo*,Microsoft Corporation");
|
||||
const [modelWhitelist, setModelWhitelist] = useState("*");
|
||||
const [webhookNotifyOnHardwareRejection, setWebhookNotifyOnHardwareRejection] = useState(false);
|
||||
const [validateAutopilotDevice, setValidateAutopilotDevice] = useState(false);
|
||||
const [validateCorporateIdentifier, setValidateCorporateIdentifier] = useState(false);
|
||||
const [validateDeviceAssociation, setValidateDeviceAssociation] = useState(false);
|
||||
const [validateCloudPcDevice, setValidateCloudPcDevice] = useState(false);
|
||||
const [validateIntuneDeviceBinding, setValidateIntuneDeviceBinding] = useState(false);
|
||||
const [dataRetentionDays, setDataRetentionDays] = useState(90);
|
||||
const [sessionTimeoutHours, setSessionTimeoutHours] = useState(5);
|
||||
|
||||
// Collector settings
|
||||
const [enablePerformanceCollector, setEnablePerformanceCollector] = useState(true);
|
||||
const [performanceCollectorInterval, setPerformanceCollectorInterval] = useState(30);
|
||||
const [helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds] = useState(HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
|
||||
// Form state: every value the sections edit, in one object (lib/settingsForm.ts).
|
||||
const [form, setForm] = useState<SettingsForm>(DEFAULT_SETTINGS_FORM);
|
||||
const formSetters = useMemo(() => settingsFormSetters(setForm), []);
|
||||
const [autopilotConsentInProgress, setAutopilotConsentInProgress] = useState(false);
|
||||
|
||||
// Agent behavior
|
||||
const [selfDestructOnComplete, setSelfDestructOnComplete] = useState(true);
|
||||
const [keepLogFile, setKeepLogFile] = useState(false);
|
||||
const [rebootOnComplete, setRebootOnComplete] = useState(false);
|
||||
const [rebootDelaySeconds, setRebootDelaySeconds] = useState(10);
|
||||
const [contactEmail, setContactEmail] = useState("");
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [enableGeoLocation, setEnableGeoLocation] = useState(true);
|
||||
const [enableTimezoneAutoSet, setEnableTimezoneAutoSet] = useState(false);
|
||||
const [enableDoGroupIdAutoSet, setEnableDoGroupIdAutoSet] = useState(false);
|
||||
const [enableImeMatchLog, setEnableImeMatchLog] = useState(false);
|
||||
const [enableGatherRuleDebugLog, setEnableGatherRuleDebugLog] = useState(false);
|
||||
const [logLevel, setLogLevel] = useState("Info");
|
||||
const [showScriptOutput, setShowScriptOutput] = useState(true);
|
||||
const [showEnrollmentSummary, setShowEnrollmentSummary] = useState(false);
|
||||
const [enrollmentSummaryTimeoutSeconds, setEnrollmentSummaryTimeoutSeconds] = useState(60);
|
||||
const [enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl] = useState("");
|
||||
const [enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds] = useState(120);
|
||||
const [enableRealmJoinWatcher, setEnableRealmJoinWatcher] = useState(false);
|
||||
const [enableConfigMgrWatcher, setEnableConfigMgrWatcher] = useState(false);
|
||||
const [keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp] = useState(false);
|
||||
|
||||
// Notification channels
|
||||
const [notificationChannels, setNotificationChannels] = useState<NotificationChannel[]>([]);
|
||||
// Notification channel test
|
||||
const [testingChannelId, setTestingChannelId] = useState<string | null>(null);
|
||||
const [testChannelResult, setTestChannelResult] = useState<{ channelId: string; success: boolean; message: string } | null>(null);
|
||||
|
||||
// SLA Targets
|
||||
const [slaTargetSuccessRate, setSlaTargetSuccessRate] = useState<number | null>(null);
|
||||
const [slaTargetMaxDurationMinutes, setSlaTargetMaxDurationMinutes] = useState<number | null>(null);
|
||||
const [slaTargetAppInstallSuccessRate, setSlaTargetAppInstallSuccessRate] = useState<number | null>(null);
|
||||
const [slaNotifyOnSuccessRateBreach, setSlaNotifyOnSuccessRateBreach] = useState(false);
|
||||
const [slaSuccessRateNotifyThreshold, setSlaSuccessRateNotifyThreshold] = useState<number | null>(null);
|
||||
const [slaNotifyOnDurationBreach, setSlaNotifyOnDurationBreach] = useState(false);
|
||||
const [slaNotifyOnAppInstallBreach, setSlaNotifyOnAppInstallBreach] = useState(false);
|
||||
const [slaNotifyOnConsecutiveFailures, setSlaNotifyOnConsecutiveFailures] = useState(false);
|
||||
const [slaConsecutiveFailureThreshold, setSlaConsecutiveFailureThreshold] = useState(5);
|
||||
|
||||
// Diagnostics
|
||||
const [diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl] = useState("");
|
||||
const [diagnosticsUploadMode, setDiagnosticsUploadMode] = useState("Off");
|
||||
// Destination defaults to "CustomerSas" so legacy rows behave exactly as before;
|
||||
// Hosted requires an explicit admin click (no silent flip).
|
||||
const [diagnosticsUploadDestination, setDiagnosticsUploadDestination] = useState("CustomerSas");
|
||||
const [tenantDiagPaths, setTenantDiagPaths] = useState<DiagnosticsLogPath[]>([]);
|
||||
// Draft inputs of the list editors: not part of the configuration until added to their list.
|
||||
const [newDiagPath, setNewDiagPath] = useState("");
|
||||
const [newDiagDesc, setNewDiagDesc] = useState("");
|
||||
|
||||
// Agent analyzers
|
||||
const [enableLocalAdminAnalyzer, setEnableLocalAdminAnalyzer] = useState(true);
|
||||
const [localAdminAllowedAccounts, setLocalAdminAllowedAccounts] = useState<string[]>([]);
|
||||
const [newAllowedAccount, setNewAllowedAccount] = useState("");
|
||||
const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false);
|
||||
const [enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer] = useState(true);
|
||||
const [enableConsoleBypassDetection, setEnableConsoleBypassDetection] = useState(true);
|
||||
|
||||
// Unrestricted mode
|
||||
const [unrestrictedMode, setUnrestrictedMode] = useState(false);
|
||||
|
||||
// Edition / trial surface (from feature-flags; fail-closed Community until loaded)
|
||||
const [editionInfo, setEditionInfo] = useState<EditionInfo>(COMMUNITY_DEFAULT);
|
||||
@@ -565,74 +382,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
}
|
||||
applyFlags(await flagsPromise);
|
||||
setConfig(data);
|
||||
|
||||
// Update form state
|
||||
setManufacturerWhitelist(data.manufacturerWhitelist);
|
||||
setModelWhitelist(data.modelWhitelist);
|
||||
setWebhookNotifyOnHardwareRejection(data.webhookNotifyOnHardwareRejection ?? false);
|
||||
setValidateAutopilotDevice(data.validateAutopilotDevice);
|
||||
setValidateCorporateIdentifier(data.validateCorporateIdentifier ?? false);
|
||||
setValidateDeviceAssociation(data.validateDeviceAssociation ?? false);
|
||||
setValidateCloudPcDevice(data.validateCloudPcDevice ?? false);
|
||||
setValidateIntuneDeviceBinding(data.validateIntuneDeviceBinding ?? false);
|
||||
setDataRetentionDays(data.dataRetentionDays ?? 90);
|
||||
setSessionTimeoutHours(data.sessionTimeoutHours ?? 5);
|
||||
setEnablePerformanceCollector(data.enablePerformanceCollector ?? true);
|
||||
setPerformanceCollectorInterval(data.performanceCollectorIntervalSeconds ?? 30);
|
||||
setHelloWaitTimeoutSeconds(data.helloWaitTimeoutSeconds ?? HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
|
||||
setSelfDestructOnComplete(data.selfDestructOnComplete ?? true);
|
||||
setKeepLogFile(data.keepLogFile ?? false);
|
||||
setRebootOnComplete(data.rebootOnComplete ?? false);
|
||||
setRebootDelaySeconds(data.rebootDelaySeconds ?? 10);
|
||||
setContactEmail(data.contactEmail ?? "");
|
||||
setCompanyName(data.companyName ?? "");
|
||||
setEnableGeoLocation(data.enableGeoLocation ?? true);
|
||||
setEnableTimezoneAutoSet(data.enableTimezoneAutoSet ?? false);
|
||||
setEnableDoGroupIdAutoSet(data.enableDoGroupIdAutoSet ?? false);
|
||||
setEnableImeMatchLog(data.enableImeMatchLog ?? false);
|
||||
setEnableGatherRuleDebugLog(data.enableGatherRuleDebugLog ?? false);
|
||||
setLogLevel(data.logLevel ?? "Info");
|
||||
setShowScriptOutput(data.showScriptOutput ?? true);
|
||||
setShowEnrollmentSummary(data.showEnrollmentSummary ?? false);
|
||||
setEnrollmentSummaryTimeoutSeconds(data.enrollmentSummaryTimeoutSeconds ?? 60);
|
||||
setEnrollmentSummaryBrandingImageUrl(data.enrollmentSummaryBrandingImageUrl ?? "");
|
||||
setEnrollmentSummaryLaunchRetrySeconds(data.enrollmentSummaryLaunchRetrySeconds ?? 120);
|
||||
setEnableRealmJoinWatcher(data.enableRealmJoinWatcher ?? false);
|
||||
setEnableConfigMgrWatcher(data.enableConfigMgrWatcher ?? false);
|
||||
setKeepAwakeDuringUserEsp(data.keepAwakeDuringUserEsp ?? false);
|
||||
// Notification channels (auto-migrates from the legacy single-webhook fields)
|
||||
setNotificationChannels(channelsFromConfig(data));
|
||||
// SLA Targets
|
||||
setSlaTargetSuccessRate(data.slaTargetSuccessRate ?? null);
|
||||
setSlaTargetMaxDurationMinutes(data.slaTargetMaxDurationMinutes ?? null);
|
||||
setSlaTargetAppInstallSuccessRate(data.slaTargetAppInstallSuccessRate ?? null);
|
||||
setSlaNotifyOnSuccessRateBreach(data.slaNotifyOnSuccessRateBreach ?? false);
|
||||
setSlaSuccessRateNotifyThreshold(data.slaSuccessRateNotifyThreshold ?? null);
|
||||
setSlaNotifyOnDurationBreach(data.slaNotifyOnDurationBreach ?? false);
|
||||
setSlaNotifyOnAppInstallBreach(data.slaNotifyOnAppInstallBreach ?? false);
|
||||
setSlaNotifyOnConsecutiveFailures(data.slaNotifyOnConsecutiveFailures ?? false);
|
||||
setSlaConsecutiveFailureThreshold(data.slaConsecutiveFailureThreshold ?? 5);
|
||||
const sasUrl = data.diagnosticsBlobSasUrl ?? "";
|
||||
setDiagnosticsBlobSasUrl(sasUrl);
|
||||
setDiagnosticsUploadMode(data.diagnosticsUploadMode ?? "Off");
|
||||
setDiagnosticsUploadDestination(data.diagnosticsUploadDestination ?? "CustomerSas");
|
||||
try {
|
||||
setTenantDiagPaths(data.diagnosticsLogPathsJson ? JSON.parse(data.diagnosticsLogPathsJson) : []);
|
||||
} catch {
|
||||
setTenantDiagPaths([]);
|
||||
}
|
||||
setEnableLocalAdminAnalyzer(data.enableLocalAdminAnalyzer ?? true);
|
||||
try {
|
||||
setLocalAdminAllowedAccounts(data.localAdminAllowedAccountsJson ? JSON.parse(data.localAdminAllowedAccountsJson) : []);
|
||||
} catch {
|
||||
setLocalAdminAllowedAccounts([]);
|
||||
}
|
||||
setEnableSoftwareInventoryAnalyzer(data.enableSoftwareInventoryAnalyzer ?? false);
|
||||
setEnableIntegrityBypassAnalyzer(data.enableIntegrityBypassAnalyzer ?? true);
|
||||
setEnableConsoleBypassDetection(data.enableConsoleBypassDetection ?? true);
|
||||
setUnrestrictedMode(data.unrestrictedMode ?? false);
|
||||
setForm(formFromConfig(data));
|
||||
|
||||
// Parse SAS expiry and fire notification to bell if needed
|
||||
const sasUrl = data.diagnosticsBlobSasUrl ?? "";
|
||||
if (sasUrl) {
|
||||
const expiry = parseSasExpiry(sasUrl);
|
||||
if (expiry) {
|
||||
@@ -668,6 +421,22 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
fetchConfiguration();
|
||||
}, [tenantId, getAccessToken, addNotification, user?.isTenantAdmin, user?.isGlobalAdmin]);
|
||||
|
||||
// Field writes from the setup guide (outside this provider): the patched fields go into the
|
||||
// loaded copy and the form alike, so they show at once and do not count as unsaved; a card's
|
||||
// unsaved edits to other fields stay.
|
||||
useEffect(() => subscribeTenantConfigPatches((patchedTenantId, fields) => {
|
||||
if (patchedTenantId !== tenantId) return;
|
||||
setConfig((prev) => (prev ? ({ ...prev, ...fields } as TenantConfiguration) : prev));
|
||||
setForm((prev) => {
|
||||
const next: SettingsForm = { ...prev };
|
||||
const writable = next as unknown as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (key in writable) writable[key] = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}), [tenantId]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fetch admins
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -740,12 +509,16 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// equal the stored row. Some of their toggles move the state before this save runs, so a failed
|
||||
// save puts it back: success syncs to the merged row, failure to the loaded one.
|
||||
const syncGateState = (stored: TenantConfiguration) => {
|
||||
setValidateAutopilotDevice(stored.validateAutopilotDevice);
|
||||
setValidateCorporateIdentifier(stored.validateCorporateIdentifier ?? false);
|
||||
setValidateDeviceAssociation(stored.validateDeviceAssociation ?? false);
|
||||
setValidateCloudPcDevice(stored.validateCloudPcDevice ?? false);
|
||||
setValidateIntuneDeviceBinding(stored.validateIntuneDeviceBinding ?? false);
|
||||
setUnrestrictedMode(stored.unrestrictedMode ?? false);
|
||||
const gates = formFromConfig(stored);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
validateAutopilotDevice: gates.validateAutopilotDevice,
|
||||
validateCorporateIdentifier: gates.validateCorporateIdentifier,
|
||||
validateDeviceAssociation: gates.validateDeviceAssociation,
|
||||
validateCloudPcDevice: gates.validateCloudPcDevice,
|
||||
validateIntuneDeviceBinding: gates.validateIntuneDeviceBinding,
|
||||
unrestrictedMode: gates.unrestrictedMode,
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -753,76 +526,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
const autopilotDeviceValidationValue = overrides?.validateAutopilotDevice ?? validateAutopilotDevice;
|
||||
const corporateIdentifierValidationValue = overrides?.validateCorporateIdentifier ?? validateCorporateIdentifier;
|
||||
const deviceAssociationValidationValue = overrides?.validateDeviceAssociation ?? validateDeviceAssociation;
|
||||
const cloudPcValidationValue = overrides?.validateCloudPcDevice ?? validateCloudPcDevice;
|
||||
const intuneDeviceBindingValue = overrides?.validateIntuneDeviceBinding ?? validateIntuneDeviceBinding;
|
||||
const unrestrictedModeValue = overrides?.unrestrictedMode ?? unrestrictedMode;
|
||||
|
||||
const updatedConfig: TenantConfiguration = {
|
||||
...config,
|
||||
manufacturerWhitelist,
|
||||
modelWhitelist,
|
||||
webhookNotifyOnHardwareRejection,
|
||||
validateAutopilotDevice: autopilotDeviceValidationValue,
|
||||
validateCorporateIdentifier: corporateIdentifierValidationValue,
|
||||
validateDeviceAssociation: deviceAssociationValidationValue,
|
||||
validateCloudPcDevice: cloudPcValidationValue,
|
||||
validateIntuneDeviceBinding: intuneDeviceBindingValue,
|
||||
dataRetentionDays,
|
||||
sessionTimeoutHours,
|
||||
enablePerformanceCollector,
|
||||
performanceCollectorIntervalSeconds: performanceCollectorInterval,
|
||||
helloWaitTimeoutSeconds,
|
||||
selfDestructOnComplete,
|
||||
keepLogFile,
|
||||
rebootOnComplete,
|
||||
rebootDelaySeconds,
|
||||
contactEmail: contactEmail.trim(),
|
||||
companyName: companyName.trim(),
|
||||
enableGeoLocation,
|
||||
enableTimezoneAutoSet,
|
||||
enableDoGroupIdAutoSet,
|
||||
enableImeMatchLog,
|
||||
enableGatherRuleDebugLog,
|
||||
logLevel,
|
||||
showScriptOutput,
|
||||
showEnrollmentSummary,
|
||||
enrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl: enrollmentSummaryBrandingImageUrl || undefined,
|
||||
enrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher,
|
||||
enableConfigMgrWatcher,
|
||||
keepAwakeDuringUserEsp,
|
||||
// Notification channels are the authoritative config; the legacy single-webhook
|
||||
// fields are cleared on save so deleting the last channel can't resurrect a zombie
|
||||
// webhook via the backend's legacy-synthesis fallback.
|
||||
notificationChannelsJson: notificationChannels.length > 0 ? JSON.stringify(notificationChannels) : "",
|
||||
webhookProviderType: 0,
|
||||
webhookUrl: undefined,
|
||||
webhookCustomHeadersJson: undefined,
|
||||
// SLA targets
|
||||
slaTargetSuccessRate: slaTargetSuccessRate ?? undefined,
|
||||
slaTargetMaxDurationMinutes: slaTargetMaxDurationMinutes ?? undefined,
|
||||
slaTargetAppInstallSuccessRate: slaTargetAppInstallSuccessRate ?? undefined,
|
||||
slaNotifyOnSuccessRateBreach,
|
||||
slaSuccessRateNotifyThreshold: slaSuccessRateNotifyThreshold ?? undefined,
|
||||
slaNotifyOnDurationBreach,
|
||||
slaNotifyOnAppInstallBreach,
|
||||
slaNotifyOnConsecutiveFailures,
|
||||
slaConsecutiveFailureThreshold,
|
||||
diagnosticsBlobSasUrl: diagnosticsBlobSasUrl || undefined,
|
||||
diagnosticsUploadMode,
|
||||
diagnosticsUploadDestination,
|
||||
diagnosticsLogPathsJson: tenantDiagPaths.length > 0 ? JSON.stringify(tenantDiagPaths) : "",
|
||||
enableLocalAdminAnalyzer,
|
||||
localAdminAllowedAccountsJson: localAdminAllowedAccounts.length > 0 ? JSON.stringify(localAdminAllowedAccounts) : "",
|
||||
enableSoftwareInventoryAnalyzer,
|
||||
enableIntegrityBypassAnalyzer,
|
||||
enableConsoleBypassDetection,
|
||||
unrestrictedMode: unrestrictedModeValue,
|
||||
};
|
||||
// A gate toggle passes its new value explicitly: the state update it made before calling
|
||||
// has not rendered yet, so the form still holds the previous value.
|
||||
const gateOverrides = Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value != null));
|
||||
const updatedConfig: TenantConfiguration = { ...configFromForm(config, form), ...gateOverrides };
|
||||
|
||||
// Per-section PATCH: send ONLY this section's fields (plus documented write-throughs)
|
||||
// that actually differ from the loaded config. The other ~90 fields never round-trip,
|
||||
@@ -857,22 +564,14 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
} finally {
|
||||
setSavingSection(null);
|
||||
}
|
||||
}, [
|
||||
tenantId, config, canEditConfig, getAccessToken,
|
||||
manufacturerWhitelist, modelWhitelist, webhookNotifyOnHardwareRejection, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice, validateIntuneDeviceBinding,
|
||||
dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval,
|
||||
helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
|
||||
contactEmail, companyName, enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog, logLevel, showScriptOutput, showEnrollmentSummary,
|
||||
enrollmentSummaryTimeoutSeconds, enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher, enableConfigMgrWatcher, keepAwakeDuringUserEsp,
|
||||
notificationChannels,
|
||||
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
|
||||
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
|
||||
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
|
||||
diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination, tenantDiagPaths,
|
||||
enableLocalAdminAnalyzer, localAdminAllowedAccounts, enableSoftwareInventoryAnalyzer,
|
||||
enableIntegrityBypassAnalyzer, enableConsoleBypassDetection, unrestrictedMode,
|
||||
]);
|
||||
}, [tenantId, config, canEditConfig, getAccessToken, form]);
|
||||
|
||||
// Nothing counts before the configuration has loaded or for a read-only viewer: their form still
|
||||
// holds the defaults, which differ from the stored row without anyone having edited it.
|
||||
const unsavedSections = useMemo(
|
||||
() => (config && canEditConfig && !loading ? sectionsWithUnsavedEdits(config, form) : NO_UNSAVED_SECTIONS),
|
||||
[config, canEditConfig, loading, form],
|
||||
);
|
||||
|
||||
// Flows without a save bar (validation gates, Unrestricted Mode, consent) branch on the outcome.
|
||||
const saveConfiguration = useCallback(
|
||||
@@ -977,17 +676,25 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
api.config.autopilotConsentUrl(tenantId, redirectUri),
|
||||
getAccessToken,
|
||||
);
|
||||
if (!data.consentUrl) {
|
||||
const consentUrl = data.consentUrl;
|
||||
if (!consentUrl) {
|
||||
throw new Error("Backend did not return a consent URL.");
|
||||
}
|
||||
|
||||
// Trace the funnel entry: this consent redirect targets the NEW app and will auto-flip
|
||||
// the tenant on verified return. AI's pagehide beacon carries it out past the redirect.
|
||||
trackEvent("consent_flow_started", { trigger, funnel: data.willAutoFlipHoming === true });
|
||||
// The consent page reloads the portal: unsaved edits on other settings pages are asked about first.
|
||||
leaveOrAsk({
|
||||
via: "consent",
|
||||
proceed: () => {
|
||||
// Trace the funnel entry: this consent redirect targets the NEW app and will auto-flip
|
||||
// the tenant on verified return. AI's pagehide beacon carries it out past the redirect.
|
||||
trackEvent("consent_flow_started", { trigger, funnel: data.willAutoFlipHoming === true });
|
||||
|
||||
sessionStorage.setItem("deviceValidationConsentPending", "true");
|
||||
sessionStorage.setItem("consentTrigger", trigger);
|
||||
window.location.href = data.consentUrl;
|
||||
sessionStorage.setItem("deviceValidationConsentPending", "true");
|
||||
sessionStorage.setItem("consentTrigger", trigger);
|
||||
window.location.href = consentUrl;
|
||||
},
|
||||
onStay: () => setAutopilotConsentInProgress(false),
|
||||
});
|
||||
} catch (err) {
|
||||
trackEvent("consent_flow_start_failed", {
|
||||
trigger,
|
||||
@@ -1214,12 +921,13 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// convenience view over the per-channel flags (checked = any enabled channel opted in;
|
||||
// toggling writes through to every channel). Without channels it edits the legacy tenant
|
||||
// flag, which the backend maps into the synthesized channel.
|
||||
const effectiveHwRejectionNotify = notificationChannels.length > 0
|
||||
? notificationChannels.some((c) => c.enabled && c.notifyOnHardwareRejection)
|
||||
: webhookNotifyOnHardwareRejection;
|
||||
const effectiveHwRejectionNotify = hardwareRejectionNotify(form);
|
||||
const setHwRejectionNotifyWriteThrough = useCallback((v: boolean) => {
|
||||
setWebhookNotifyOnHardwareRejection(v);
|
||||
setNotificationChannels((prev) => prev.map((c) => ({ ...c, notifyOnHardwareRejection: v })));
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
webhookNotifyOnHardwareRejection: v,
|
||||
notificationChannels: current.notificationChannels.map((c) => ({ ...c, notifyOnHardwareRejection: v })),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSaveAgentParameters = useCallback(() => saveSection("agentParameters"), [saveSection]);
|
||||
@@ -1255,9 +963,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
* (saveConfiguration re-syncs the gates).
|
||||
*/
|
||||
const handleToggleCloudPcValidation = useCallback(async (newValue: boolean) => {
|
||||
setValidateCloudPcDevice(newValue);
|
||||
formSetters.setValidateCloudPcDevice(newValue);
|
||||
await saveConfiguration("autopilotValidation", { validateCloudPcDevice: newValue });
|
||||
}, [saveConfiguration]);
|
||||
}, [formSetters, saveConfiguration]);
|
||||
|
||||
/**
|
||||
* Toggle Intune Enrollment Validation (field name validateIntuneDeviceBinding). No consent
|
||||
@@ -1267,9 +975,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
* a failed save, like the Cloud PC toggle.
|
||||
*/
|
||||
const handleToggleIntuneDeviceBinding = useCallback(async (newValue: boolean) => {
|
||||
setValidateIntuneDeviceBinding(newValue);
|
||||
formSetters.setValidateIntuneDeviceBinding(newValue);
|
||||
await saveConfiguration("autopilotValidation", { validateIntuneDeviceBinding: newValue });
|
||||
}, [saveConfiguration]);
|
||||
}, [formSetters, saveConfiguration]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Admin management handlers
|
||||
@@ -1509,95 +1217,49 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// depends on nearly every field); the memo only cuts the externally-triggered cascade.
|
||||
// The dependency array is enforced by react-hooks/exhaustive-deps (lint cap = 0).
|
||||
const value = useMemo<TenantConfigContextValue>(() => ({
|
||||
config, loading, canEditConfig, savingSection,
|
||||
config, loading, canEditConfig, savingSection, unsavedSections,
|
||||
error, setError, successMessage, setSuccessMessage,
|
||||
|
||||
// Every edited value and its setter; the hardware switch below replaces its pair.
|
||||
...form,
|
||||
...formSetters,
|
||||
|
||||
// Edition / trial
|
||||
editionInfo, startingTrial, startTrial,
|
||||
appHomingFunnelActive, homingFlipped, homingMissingRoles,
|
||||
graphAccessRejected, graphAccessAppMissing,
|
||||
|
||||
// Validation
|
||||
validateAutopilotDevice, setValidateAutopilotDevice,
|
||||
validateCorporateIdentifier, setValidateCorporateIdentifier,
|
||||
validateDeviceAssociation, setValidateDeviceAssociation,
|
||||
validateCloudPcDevice, setValidateCloudPcDevice,
|
||||
validateIntuneDeviceBinding, setValidateIntuneDeviceBinding,
|
||||
handleToggleCloudPcValidation,
|
||||
handleToggleIntuneDeviceBinding,
|
||||
saveValidationGate,
|
||||
autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
|
||||
|
||||
// Hardware whitelist
|
||||
manufacturerWhitelist, setManufacturerWhitelist,
|
||||
modelWhitelist, setModelWhitelist,
|
||||
webhookNotifyOnHardwareRejection: effectiveHwRejectionNotify,
|
||||
setWebhookNotifyOnHardwareRejection: setHwRejectionNotifyWriteThrough,
|
||||
handleSaveHardwareWhitelist,
|
||||
|
||||
// Agent settings
|
||||
enablePerformanceCollector, setEnablePerformanceCollector,
|
||||
performanceCollectorInterval, setPerformanceCollectorInterval,
|
||||
helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds,
|
||||
selfDestructOnComplete, setSelfDestructOnComplete,
|
||||
keepLogFile, setKeepLogFile,
|
||||
rebootOnComplete, setRebootOnComplete,
|
||||
rebootDelaySeconds, setRebootDelaySeconds,
|
||||
contactEmail, setContactEmail,
|
||||
companyName, setCompanyName,
|
||||
// Agent settings and contact
|
||||
handleSaveContact,
|
||||
enableGeoLocation, setEnableGeoLocation,
|
||||
enableTimezoneAutoSet, setEnableTimezoneAutoSet,
|
||||
enableDoGroupIdAutoSet, setEnableDoGroupIdAutoSet,
|
||||
enableImeMatchLog, setEnableImeMatchLog,
|
||||
enableGatherRuleDebugLog, setEnableGatherRuleDebugLog,
|
||||
logLevel, setLogLevel,
|
||||
showScriptOutput, setShowScriptOutput,
|
||||
showEnrollmentSummary, setShowEnrollmentSummary,
|
||||
enrollmentSummaryTimeoutSeconds, setEnrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl,
|
||||
enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher, setEnableRealmJoinWatcher,
|
||||
enableConfigMgrWatcher, setEnableConfigMgrWatcher,
|
||||
keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp,
|
||||
handleSaveAgentParameters,
|
||||
handleSaveAgentCollectors,
|
||||
|
||||
// Agent analyzers
|
||||
enableLocalAdminAnalyzer, setEnableLocalAdminAnalyzer,
|
||||
localAdminAllowedAccounts, setLocalAdminAllowedAccounts,
|
||||
newAllowedAccount, setNewAllowedAccount,
|
||||
enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer,
|
||||
enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer,
|
||||
enableConsoleBypassDetection, setEnableConsoleBypassDetection,
|
||||
handleSaveAgentAnalyzers,
|
||||
|
||||
// Unrestricted mode
|
||||
unrestrictedMode, setUnrestrictedMode,
|
||||
handleSaveUnrestrictedMode,
|
||||
|
||||
// Notifications
|
||||
notificationChannels, setNotificationChannels,
|
||||
testingChannelId, testChannelResult,
|
||||
handleTestChannel, handleSaveNotifications,
|
||||
|
||||
// SLA Targets
|
||||
slaTargetSuccessRate, setSlaTargetSuccessRate,
|
||||
slaTargetMaxDurationMinutes, setSlaTargetMaxDurationMinutes,
|
||||
slaTargetAppInstallSuccessRate, setSlaTargetAppInstallSuccessRate,
|
||||
slaNotifyOnSuccessRateBreach, setSlaNotifyOnSuccessRateBreach,
|
||||
slaSuccessRateNotifyThreshold, setSlaSuccessRateNotifyThreshold,
|
||||
slaNotifyOnDurationBreach, setSlaNotifyOnDurationBreach,
|
||||
slaNotifyOnAppInstallBreach, setSlaNotifyOnAppInstallBreach,
|
||||
slaNotifyOnConsecutiveFailures, setSlaNotifyOnConsecutiveFailures,
|
||||
slaConsecutiveFailureThreshold, setSlaConsecutiveFailureThreshold,
|
||||
handleSaveSlaTargets,
|
||||
|
||||
// Diagnostics
|
||||
diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl,
|
||||
diagnosticsUploadMode, setDiagnosticsUploadMode,
|
||||
diagnosticsUploadDestination, setDiagnosticsUploadDestination,
|
||||
tenantDiagPaths, setTenantDiagPaths,
|
||||
newDiagPath, setNewDiagPath,
|
||||
newDiagDesc, setNewDiagDesc,
|
||||
handleSaveDiagnostics,
|
||||
@@ -1615,8 +1277,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
fetchBootstrapSessions, createBootstrapSession, revokeBootstrapSession,
|
||||
|
||||
// Data management
|
||||
dataRetentionDays, setDataRetentionDays,
|
||||
sessionTimeoutHours, setSessionTimeoutHours,
|
||||
handleSaveDataManagement,
|
||||
|
||||
// Offboarding
|
||||
@@ -1629,41 +1289,29 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// Auth
|
||||
user, getAccessToken,
|
||||
}), [
|
||||
config, loading, canEditConfig, savingSection, error, successMessage,
|
||||
config, loading, canEditConfig, savingSection, unsavedSections, error, successMessage,
|
||||
form, formSetters,
|
||||
editionInfo, startingTrial, startTrial, appHomingFunnelActive, homingFlipped, homingMissingRoles,
|
||||
graphAccessRejected, graphAccessAppMissing,
|
||||
validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation,
|
||||
validateCloudPcDevice, handleToggleCloudPcValidation,
|
||||
validateIntuneDeviceBinding, handleToggleIntuneDeviceBinding,
|
||||
handleToggleCloudPcValidation, handleToggleIntuneDeviceBinding,
|
||||
saveValidationGate, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
|
||||
manufacturerWhitelist, modelWhitelist, effectiveHwRejectionNotify, setHwRejectionNotifyWriteThrough,
|
||||
effectiveHwRejectionNotify, setHwRejectionNotifyWriteThrough,
|
||||
handleSaveHardwareWhitelist,
|
||||
enablePerformanceCollector, performanceCollectorInterval, helloWaitTimeoutSeconds,
|
||||
selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
|
||||
contactEmail, companyName, handleSaveContact,
|
||||
enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog,
|
||||
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher, enableConfigMgrWatcher, keepAwakeDuringUserEsp,
|
||||
handleSaveContact,
|
||||
handleSaveAgentParameters,
|
||||
handleSaveAgentCollectors,
|
||||
enableLocalAdminAnalyzer, localAdminAllowedAccounts, newAllowedAccount,
|
||||
enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, enableConsoleBypassDetection,
|
||||
newAllowedAccount,
|
||||
handleSaveAgentAnalyzers,
|
||||
unrestrictedMode, handleSaveUnrestrictedMode,
|
||||
notificationChannels, testingChannelId, testChannelResult,
|
||||
handleSaveUnrestrictedMode,
|
||||
testingChannelId, testChannelResult,
|
||||
handleTestChannel, handleSaveNotifications,
|
||||
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
|
||||
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
|
||||
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
|
||||
handleSaveSlaTargets,
|
||||
diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination,
|
||||
tenantDiagPaths, newDiagPath, newDiagDesc,
|
||||
newDiagPath, newDiagDesc,
|
||||
handleSaveDiagnostics,
|
||||
admins, loadingAdmins, newAdminEmail, newMemberRole, newMemberKind, addingAdmin, removingAdmin, togglingAdmin,
|
||||
handleAddAdmin, handleRemoveAdmin, handleToggleTenantAdmin, handleUpdatePermissions,
|
||||
bootstrapSessions, bootstrapLoading, fetchBootstrapSessions, createBootstrapSession, revokeBootstrapSession,
|
||||
dataRetentionDays, sessionTimeoutHours, handleSaveDataManagement,
|
||||
handleSaveDataManagement,
|
||||
showOffboardDialog, offboardConfirmText, offboarding, offboardError, handleOffboard,
|
||||
offboardingInProgress, handleDrainBarrierElapsed,
|
||||
user, getAccessToken,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { EXPANDABLE_NAV_GROUPS } from "@/lib/globalNavConfig";
|
||||
import { isMsalRedirectInFlight } from "@/lib/redirectRecovery";
|
||||
import { trustedRoute } from "@/lib/routes";
|
||||
import { publishUnsavedChanges, type LeaveRequest } from "@/lib/unsavedChanges";
|
||||
import { useTenantConfig } from "./TenantConfigContext";
|
||||
import { SECTION_FIELD_MAP } from "./sectionFieldMap";
|
||||
import { leavesSettings, sameTabLinkTarget } from "./lib/leaveGuard";
|
||||
import UnsavedChangesDialog from "./components/UnsavedChangesDialog";
|
||||
|
||||
/** Sidebar label per page, so the dialog names a page the way the sidebar does. */
|
||||
const SIDEBAR_LABELS = new Map<string, string>(
|
||||
EXPANDABLE_NAV_GROUPS.flatMap((group) => group.items.flatMap((item) => item.items.map((sub) => [sub.href, sub.label] as const))),
|
||||
);
|
||||
|
||||
/**
|
||||
* Keeps unsaved settings edits from being lost on the way out of the settings area. It publishes their
|
||||
* pages for the sidebar, asks before a link or code leaves the area, and lets the browser ask before
|
||||
* the tab closes or reloads. Moving between settings pages never asks: the edits stay in the layout.
|
||||
*/
|
||||
export function UnsavedChangesGuard() {
|
||||
const { unsavedSections } = useTenantConfig();
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState<LeaveRequest | null>(null);
|
||||
// Set once the user chose to leave: from then on nothing holds the way out.
|
||||
const leavingRef = useRef(false);
|
||||
|
||||
// One entry per page, in map order: the Agent Settings page holds two sections.
|
||||
const pages = useMemo(
|
||||
() => [...new Set([...unsavedSections].map((section) => SECTION_FIELD_MAP[section].page))]
|
||||
.map((href) => ({ href, label: SIDEBAR_LABELS.get(href) ?? href })),
|
||||
[unsavedSections],
|
||||
);
|
||||
const hasUnsaved = pages.length > 0;
|
||||
|
||||
const requestLeave = useCallback((request: LeaveRequest) => {
|
||||
if (leavingRef.current || (request.href !== undefined && !leavesSettings(request.href))) {
|
||||
request.proceed();
|
||||
return;
|
||||
}
|
||||
setPending(request);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
publishUnsavedChanges(pages.map((page) => page.href), requestLeave);
|
||||
}, [pages, requestLeave]);
|
||||
useEffect(() => () => publishUnsavedChanges([], null), []);
|
||||
|
||||
// Links of every kind (sidebar, navbar, page content): caught before the link's own handler
|
||||
// navigates. A new tab, a download or another site are left alone.
|
||||
useEffect(() => {
|
||||
if (!hasUnsaved) return;
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (leavingRef.current) return;
|
||||
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
const target = sameTabLinkTarget(
|
||||
event,
|
||||
{ href: link.href, target: link.target, download: link.hasAttribute("download") },
|
||||
window.location.origin,
|
||||
);
|
||||
if (target === null || !leavesSettings(target)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setPending({ href: target, via: "link", proceed: () => router.push(trustedRoute(target)) });
|
||||
};
|
||||
document.addEventListener("click", onClick, true);
|
||||
return () => document.removeEventListener("click", onClick, true);
|
||||
}, [hasUnsaved, router]);
|
||||
|
||||
// Closing the tab, reloading, typing another address: the browser's own prompt.
|
||||
useEffect(() => {
|
||||
if (!hasUnsaved) return;
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
// Never hold a sign-in or sign-out redirect: a cancelled one leaves MSAL's interaction status
|
||||
// behind, and the next sign-in fails.
|
||||
if (leavingRef.current || isMsalRedirectInFlight()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, [hasUnsaved]);
|
||||
|
||||
const stay = useCallback(() => {
|
||||
if (!pending) return;
|
||||
trackEvent("settings_unsaved_prompt", { outcome: "stay", via: pending.via, pages: pages.length });
|
||||
setPending(null);
|
||||
pending.onStay?.();
|
||||
}, [pending, pages.length]);
|
||||
|
||||
const leave = useCallback(() => {
|
||||
if (!pending) return;
|
||||
trackEvent("settings_unsaved_prompt", { outcome: "leave", via: pending.via, pages: pages.length });
|
||||
leavingRef.current = true;
|
||||
setPending(null);
|
||||
pending.proceed();
|
||||
}, [pending, pages.length]);
|
||||
|
||||
if (!pending) return null;
|
||||
return <UnsavedChangesDialog pages={pages} onStay={stay} onLeave={leave} />;
|
||||
}
|
||||
@@ -7,12 +7,34 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SHARED_MANIFEST } from "@/lib/generated/shared-manifests.generated";
|
||||
import { SECTION_FIELD_MAP } from "../sectionFieldMap";
|
||||
import { EXPANDABLE_NAV_GROUPS } from "@/lib/globalNavConfig";
|
||||
import { SAVE_BAR_SECTIONS, SECTION_FIELD_MAP } from "../sectionFieldMap";
|
||||
|
||||
const sections = Object.entries(SECTION_FIELD_MAP) as Array<
|
||||
[string, { fields: readonly string[]; alsoWrites?: readonly string[] }]
|
||||
[string, { fields: readonly string[]; alsoWrites?: readonly string[]; page: string; immediate?: true }]
|
||||
>;
|
||||
|
||||
/** Test-local: the sidebar page each section lives on (where its unsaved edits are marked). */
|
||||
const EXPECTED_PAGES: Record<string, string> = {
|
||||
hardwareWhitelist: "/settings/tenant/hardware-whitelist",
|
||||
autopilotValidation: "/settings/tenant/autopilot",
|
||||
notifications: "/settings/tenant/notifications",
|
||||
slaTargets: "/settings/tenant/sla-targets",
|
||||
contact: "/settings/tenant/contact",
|
||||
agentParameters: "/settings/agent/settings",
|
||||
agentCollectors: "/settings/agent/settings",
|
||||
agentAnalyzers: "/settings/agent/analyzers",
|
||||
diagnostics: "/settings/agent/diagnostics",
|
||||
unrestrictedMode: "/settings/agent/unrestricted-mode",
|
||||
dataManagement: "/settings/management/data",
|
||||
};
|
||||
|
||||
/** Test-local: the sections with a Save bar. The validation gates and Unrestricted Mode save on change. */
|
||||
const EXPECTED_SAVE_BAR_SECTIONS = [
|
||||
"hardwareWhitelist", "notifications", "slaTargets", "contact", "agentParameters",
|
||||
"agentCollectors", "agentAnalyzers", "diagnostics", "dataManagement",
|
||||
].sort();
|
||||
|
||||
/**
|
||||
* Test-local, independent list of every field the Settings UI edits (one owner section
|
||||
* each). Update it together with SECTION_FIELD_MAP when a section gains/loses a field —
|
||||
@@ -61,6 +83,7 @@ const SERVER_DENIED_FIELDS = new Set([
|
||||
"tenantId", "domainName", "partitionKey", "rowKey", "timestamp", "eTag",
|
||||
"lastUpdated", "updatedBy", "onboardedAt", "onboardedBy", "dpaVersion",
|
||||
"dpaAcceptancePending", "dpaAcceptedBy", "dpaAcceptedAt",
|
||||
"setupGuideSettingsReviewedAt", "setupGuideHardwareReviewedAt", "setupGuideDismissedAt", "setupGuideDismissedBy",
|
||||
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
|
||||
"graphAccessRejectedSince", "graphAccessRejectedCode", "graphAccessRejectionConfirmedAt",
|
||||
"intuneDeviceBindingPermissionMissingSince", "intuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
@@ -121,6 +144,23 @@ describe("SECTION_FIELD_MAP parity", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("every section names the page that holds it", () => {
|
||||
expect(Object.fromEntries(sections.map(([section, spec]) => [section, spec.page]))).toEqual(EXPECTED_PAGES);
|
||||
});
|
||||
|
||||
it("every page is a link in the sidebar, where its unsaved edits are marked", () => {
|
||||
const sidebarHrefs = new Set<string>(
|
||||
EXPANDABLE_NAV_GROUPS.flatMap((group) => group.items.flatMap((item) => item.items.map((sub) => sub.href))),
|
||||
);
|
||||
for (const [section, spec] of sections) {
|
||||
expect(sidebarHrefs, `${section}: '${spec.page}' is not a sidebar link`).toContain(spec.page);
|
||||
}
|
||||
});
|
||||
|
||||
it("only the sections with a Save bar can hold unsaved edits", () => {
|
||||
expect([...SAVE_BAR_SECTIONS].sort()).toEqual(EXPECTED_SAVE_BAR_SECTIONS);
|
||||
});
|
||||
|
||||
it("no section owns a GA-only field a tenant admin could not save", () => {
|
||||
// GaOnlyFields (TenantConfigPatchService), camelCased — independent copy.
|
||||
const gaOnly = new Set([
|
||||
|
||||
@@ -15,6 +15,7 @@ export function SectionAgentAnalyzers() {
|
||||
enableConsoleBypassDetection, setEnableConsoleBypassDetection,
|
||||
handleSaveAgentAnalyzers,
|
||||
savingSection,
|
||||
unsavedSections,
|
||||
} = useTenantConfig();
|
||||
|
||||
return (
|
||||
@@ -35,6 +36,7 @@ export function SectionAgentAnalyzers() {
|
||||
setEnableConsoleBypassDetection={setEnableConsoleBypassDetection}
|
||||
onSave={handleSaveAgentAnalyzers}
|
||||
saving={savingSection === "agentAnalyzers"}
|
||||
unsaved={unsavedSections.has("agentAnalyzers")}
|
||||
readOnly={!canEditConfig}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -32,6 +32,7 @@ export function SectionAgentSettings() {
|
||||
handleSaveAgentParameters,
|
||||
handleSaveAgentCollectors,
|
||||
savingSection,
|
||||
unsavedSections,
|
||||
} = useTenantConfig();
|
||||
|
||||
return (
|
||||
@@ -72,6 +73,7 @@ export function SectionAgentSettings() {
|
||||
setEnrollmentSummaryLaunchRetrySeconds={setEnrollmentSummaryLaunchRetrySeconds}
|
||||
onSave={handleSaveAgentParameters}
|
||||
saving={savingSection === "agentParameters"}
|
||||
unsaved={unsavedSections.has("agentParameters")}
|
||||
readOnly={!canEditConfig}
|
||||
/>
|
||||
<AgentCollectorsSection
|
||||
@@ -87,6 +89,7 @@ export function SectionAgentSettings() {
|
||||
setEnableConfigMgrWatcher={setEnableConfigMgrWatcher}
|
||||
onSave={handleSaveAgentCollectors}
|
||||
saving={savingSection === "agentCollectors"}
|
||||
unsaved={unsavedSections.has("agentCollectors")}
|
||||
readOnly={!canEditConfig}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -18,6 +18,7 @@ export function SectionDiagnostics() {
|
||||
unrestrictedMode,
|
||||
handleSaveDiagnostics,
|
||||
savingSection,
|
||||
unsavedSections,
|
||||
} = useTenantConfig();
|
||||
// Built-in sections + platform-wide paths (MemberRead) — read-only context for every role.
|
||||
const catalog = useDiagnosticsPathsCatalog();
|
||||
@@ -47,6 +48,7 @@ export function SectionDiagnostics() {
|
||||
unrestrictedMode={unrestrictedMode}
|
||||
onSave={handleSaveDiagnostics}
|
||||
saving={savingSection === "diagnostics"}
|
||||
unsaved={unsavedSections.has("diagnostics")}
|
||||
readOnly={!canEditConfig}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import SaveBar from "./SaveBar";
|
||||
import ReadOnlyFieldset from "./ReadOnlyFieldset";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import {
|
||||
BUILT_IN_GROUPS,
|
||||
formatAllowedEntry,
|
||||
isBuiltInName,
|
||||
isCoveredByBuiltIn,
|
||||
parseAllowedEntry,
|
||||
validateNewEntry,
|
||||
type AllowedRole,
|
||||
} from "../lib/allowedAccounts";
|
||||
|
||||
interface AgentAnalyzersSectionProps {
|
||||
enableLocalAdminAnalyzer: boolean;
|
||||
@@ -20,23 +30,33 @@ interface AgentAnalyzersSectionProps {
|
||||
setEnableConsoleBypassDetection: (value: boolean) => void;
|
||||
onSave: () => Promise<void> | void;
|
||||
saving: boolean;
|
||||
/** Unsaved edits of this card: the Save bar pins itself and enables Save. Left out, Save stays available. */
|
||||
unsaved?: boolean;
|
||||
/** Read-only viewer (Operator): analyzers visible but inert, no Save bar. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const BUILTIN_ACCOUNTS = [
|
||||
"Administrator",
|
||||
"Guest",
|
||||
"DefaultAccount",
|
||||
"WDAGUtilityAccount",
|
||||
"defaultuser0",
|
||||
"defaultuser1",
|
||||
"defaultuser2",
|
||||
"Public",
|
||||
"Default",
|
||||
"Default User",
|
||||
"All Users",
|
||||
];
|
||||
const ROLE_LABELS: Record<AllowedRole, string> = {
|
||||
any: "Admin allowed",
|
||||
user: "Standard user only",
|
||||
};
|
||||
|
||||
function BuiltInGroup({ label, names }: { label: string; names: readonly string[] }) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-1.5 sm:gap-3">
|
||||
<p className="text-xs text-gray-500 sm:w-36 sm:shrink-0 sm:pt-1">{label}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{names.map((name) => (
|
||||
<span key={name} className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600 border border-gray-200">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const namesOf = (entries: readonly { name: string }[]) => entries.map((e) => e.name);
|
||||
|
||||
export default function AgentAnalyzersSection({
|
||||
enableLocalAdminAnalyzer,
|
||||
@@ -53,24 +73,36 @@ export default function AgentAnalyzersSection({
|
||||
setEnableConsoleBypassDetection,
|
||||
onSave,
|
||||
saving,
|
||||
unsaved,
|
||||
readOnly = false,
|
||||
}: AgentAnalyzersSectionProps) {
|
||||
const [newRole, setNewRole] = useState<AllowedRole>("any");
|
||||
const trimmed = newAllowedAccount.trim();
|
||||
// An entry typed in full ("kiosk|user") keeps its own role; otherwise the role picker decides.
|
||||
const typed = parseAllowedEntry(trimmed);
|
||||
const newEntry = typed?.role === "user" ? trimmed : formatAllowedEntry(trimmed, newRole);
|
||||
const newPattern = typed?.pattern ?? trimmed;
|
||||
const isDuplicate =
|
||||
trimmed !== "" &&
|
||||
(localAdminAllowedAccounts.some((a) => a.toLowerCase() === trimmed.toLowerCase()) ||
|
||||
BUILTIN_ACCOUNTS.some((a) => a.toLowerCase() === trimmed.toLowerCase()));
|
||||
// Wildcard patterns (* / ?) need at least 2 literal characters — a bare "*"
|
||||
// would silently disable the analyzer for the whole tenant.
|
||||
const isTooBroad =
|
||||
/[*?]/.test(trimmed) && trimmed.replace(/[*?]/g, "").length < 2;
|
||||
(isBuiltInName(newPattern) ||
|
||||
localAdminAllowedAccounts.some((a) => (parseAllowedEntry(a)?.pattern ?? a).toLowerCase() === newPattern.toLowerCase()));
|
||||
// Same rules the backend applies to a new entry (a bare "*" would disable the analyzer).
|
||||
const entryError = trimmed === "" || isDuplicate ? null : validateNewEntry(newEntry);
|
||||
|
||||
const addAccount = () => {
|
||||
if (!trimmed || isDuplicate || isTooBroad) return;
|
||||
setLocalAdminAllowedAccounts([...localAdminAllowedAccounts, trimmed]);
|
||||
if (!trimmed || isDuplicate || entryError) return;
|
||||
setLocalAdminAllowedAccounts([...localAdminAllowedAccounts, newEntry]);
|
||||
setNewAllowedAccount("");
|
||||
};
|
||||
|
||||
const setEntryRole = (index: number, role: AllowedRole) => {
|
||||
const parsed = parseAllowedEntry(localAdminAllowedAccounts[index]);
|
||||
if (!parsed) return;
|
||||
setLocalAdminAllowedAccounts(
|
||||
localAdminAllowedAccounts.map((entry, i) => (i === index ? formatAllowedEntry(parsed.pattern, role) : entry)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="agent-analyzers" className="bg-white rounded-lg shadow">
|
||||
<SectionCardHeader
|
||||
@@ -111,23 +143,25 @@ export default function AgentAnalyzersSection({
|
||||
<div className={`p-4 rounded-lg border border-gray-200 transition-opacity ${!enableLocalAdminAnalyzer ? 'opacity-50' : ''}`}>
|
||||
<p className="font-medium text-gray-900 mb-1">Allowed Local Accounts</p>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Accounts listed here are considered expected on enrolled devices and will not trigger alerts. These are merged with the built-in defaults on the agent.
|
||||
Accounts listed here are considered expected on enrolled devices and will not trigger alerts. These are merged with the built-in entries on the agent.
|
||||
Wildcards are supported: <code className="px-1 bg-gray-100 rounded">*</code> matches any sequence of characters,{" "}
|
||||
<code className="px-1 bg-gray-100 rounded">?</code> exactly one — e.g.{" "}
|
||||
<code className="px-1 bg-gray-100 rounded">adm-*</code> allows generated accounts like <code className="px-1 bg-gray-100 rounded">adm-83921</code>.
|
||||
Matching is case-insensitive and applies to both account names and profile folders.
|
||||
Choose <span className="font-medium text-gray-700">Standard user only</span> for an account that must never be a local administrator:
|
||||
it is still reported as soon as it is a member of the Administrators group.
|
||||
</p>
|
||||
|
||||
{/* Built-in accounts (read-only) */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">Built-in (always allowed)</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{BUILTIN_ACCOUNTS.map((account) => (
|
||||
<span key={account} className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600 border border-gray-200">
|
||||
{account}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Built-in entries (read-only) */}
|
||||
<div className="mb-3 space-y-2">
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">Built-in (always applied)</p>
|
||||
<BuiltInGroup label={ROLE_LABELS.any} names={namesOf(BUILT_IN_GROUPS.adminAllowed)} />
|
||||
<BuiltInGroup label={ROLE_LABELS.user} names={namesOf(BUILT_IN_GROUPS.standardUser)} />
|
||||
<BuiltInGroup label="Profile folders only" names={namesOf(BUILT_IN_GROUPS.folders)} />
|
||||
<p className="text-xs text-gray-500">
|
||||
Built-in accounts are recognised in every language. Other accounts that only carry the name{" "}
|
||||
{namesOf(BUILT_IN_GROUPS.sharedNames).join(", ")} are allowed as standard users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom allowed accounts */}
|
||||
@@ -135,49 +169,76 @@ export default function AgentAnalyzersSection({
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">Your allowed accounts</p>
|
||||
<div className="space-y-1.5">
|
||||
{localAdminAllowedAccounts.map((account, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
|
||||
<p className="text-sm text-rose-900">{account}</p>
|
||||
<button
|
||||
onClick={() => setLocalAdminAllowedAccounts(localAdminAllowedAccounts.filter((_, i) => i !== idx))}
|
||||
className="ml-2 flex-shrink-0 text-rose-400 hover:text-red-600 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{localAdminAllowedAccounts.map((account, idx) => {
|
||||
const parsed = parseAllowedEntry(account);
|
||||
return (
|
||||
<div key={idx} className="flex flex-wrap items-center gap-x-3 gap-y-1 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
|
||||
<p className="flex-1 basis-40 min-w-0 truncate text-sm text-rose-900">{parsed?.pattern ?? account}</p>
|
||||
{!parsed ? (
|
||||
<span className="text-xs text-red-600">Not valid — the agent ignores it</span>
|
||||
) : isCoveredByBuiltIn(account) ? (
|
||||
<span className="text-xs text-rose-600">Built in — not needed</span>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={parsed?.role ?? "any"}
|
||||
disabled={!parsed}
|
||||
onChange={(e) => setEntryRole(idx, e.target.value as AllowedRole)}
|
||||
aria-label={`Role for ${parsed?.pattern ?? account}`}
|
||||
className="px-2 py-1 border border-rose-200 rounded-lg text-xs text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-rose-500 focus:border-rose-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="any">{ROLE_LABELS.any}</option>
|
||||
<option value="user">{ROLE_LABELS.user}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setLocalAdminAllowedAccounts(localAdminAllowedAccounts.filter((_, i) => i !== idx))}
|
||||
className="flex-shrink-0 text-rose-400 hover:text-red-600 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new account */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Account name or pattern (e.g. SupportAdmin, adm-*)"
|
||||
value={newAllowedAccount}
|
||||
onChange={(e) => setNewAllowedAccount(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addAccount(); } }}
|
||||
className="flex-1 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:border-rose-500"
|
||||
className="flex-1 basis-56 min-w-0 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:border-rose-500"
|
||||
/>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value as AllowedRole)}
|
||||
aria-label="Role for the new account"
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-rose-500 focus:border-rose-500"
|
||||
>
|
||||
<option value="any">{ROLE_LABELS.any}</option>
|
||||
<option value="user">{ROLE_LABELS.user}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={addAccount}
|
||||
disabled={!trimmed || isDuplicate || isTooBroad}
|
||||
disabled={!trimmed || isDuplicate || entryError !== null}
|
||||
className="px-4 py-1.5 bg-rose-600 text-white rounded-lg text-sm font-medium hover:bg-rose-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{isDuplicate && (
|
||||
<p className="text-xs text-red-500 mt-1">This account is already in the list.</p>
|
||||
<p className="text-xs text-red-500 mt-1">This account is already in the list or built in.</p>
|
||||
)}
|
||||
{isTooBroad && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
Pattern is too broad — it needs at least 2 characters besides <code>*</code>/<code>?</code>, otherwise the analyzer would be effectively disabled.
|
||||
</p>
|
||||
{entryError && (
|
||||
<p className="text-xs text-red-500 mt-1">{entryError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -185,7 +246,7 @@ export default function AgentAnalyzersSection({
|
||||
<div className="border-t border-gray-200" />
|
||||
|
||||
{/* ── Software Inventory & Vulnerability Analyzer ── */}
|
||||
<div>
|
||||
<div data-tour="software-inventory">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-3">Software Inventory & Vulnerability Analyzer</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Collects installed software from the Windows registry at enrollment start and completion.
|
||||
@@ -273,7 +334,7 @@ export default function AgentAnalyzersSection({
|
||||
</ReadOnlyFieldset>
|
||||
|
||||
{/* Save */}
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} unsaved={unsaved} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,8 @@ interface AgentCollectorsSectionProps {
|
||||
setEnableConfigMgrWatcher: (value: boolean) => void;
|
||||
onSave: () => Promise<void> | void;
|
||||
saving: boolean;
|
||||
/** Unsaved edits of this card: the Save bar pins itself and enables Save. Left out, Save stays available. */
|
||||
unsaved?: boolean;
|
||||
/** Read-only viewer (Operator): settings visible but inert, no Save bar. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
@@ -41,6 +43,7 @@ export default function AgentCollectorsSection({
|
||||
setEnableConfigMgrWatcher,
|
||||
onSave,
|
||||
saving,
|
||||
unsaved,
|
||||
readOnly = false,
|
||||
}: AgentCollectorsSectionProps) {
|
||||
return (
|
||||
@@ -147,7 +150,7 @@ export default function AgentCollectorsSection({
|
||||
</div>
|
||||
</ReadOnlyFieldset>
|
||||
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} unsaved={unsaved} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,8 @@ interface AgentParametersSectionProps {
|
||||
setEnrollmentSummaryLaunchRetrySeconds: (value: number) => void;
|
||||
onSave: () => Promise<void> | void;
|
||||
saving: boolean;
|
||||
/** Unsaved edits of this card: the Save bar pins itself and enables Save. Left out, Save stays available. */
|
||||
unsaved?: boolean;
|
||||
/** Read-only viewer (Operator): settings visible but inert, no Save bar. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
@@ -79,6 +81,7 @@ export default function AgentParametersSection({
|
||||
setEnrollmentSummaryLaunchRetrySeconds,
|
||||
onSave,
|
||||
saving,
|
||||
unsaved,
|
||||
readOnly = false,
|
||||
}: AgentParametersSectionProps) {
|
||||
return (
|
||||
@@ -153,7 +156,7 @@ export default function AgentParametersSection({
|
||||
)}
|
||||
|
||||
{/* Geo Location */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
|
||||
<div data-tour="geo-location" className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Geo-Location Detection</p>
|
||||
<p className="text-sm text-gray-500">Capture device location, ISP and network info at enrollment start (queries external IP service)</p>
|
||||
@@ -166,7 +169,7 @@ export default function AgentParametersSection({
|
||||
|
||||
{/* Timezone Auto-Set (sub-toggle of Geo-Location) */}
|
||||
{enableGeoLocation && (
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
|
||||
<div data-tour="timezone" className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Set Timezone Automatically</p>
|
||||
<p className="text-sm text-gray-500">Set the device timezone based on IP geolocation result (uses tzutil /s)</p>
|
||||
@@ -335,7 +338,7 @@ export default function AgentParametersSection({
|
||||
</div>
|
||||
</ReadOnlyFieldset>
|
||||
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} unsaved={unsaved} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
-3
@@ -190,8 +190,8 @@ export default function AutopilotValidationSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Windows Autopilot (v1) */}
|
||||
<div className="space-y-3">
|
||||
{/* Windows Autopilot (v1) — data-tour: the setup guide's spotlight target */}
|
||||
<div className="space-y-3" data-tour="autopilot-validation">
|
||||
<p className="text-sm font-semibold text-gray-700 tracking-wide">Windows Autopilot</p>
|
||||
<label className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -212,7 +212,7 @@ export default function AutopilotValidationSection({
|
||||
</div>
|
||||
|
||||
{/* Windows Autopilot Device Preparation (v2) */}
|
||||
<div className="border-t border-gray-100 pt-5 space-y-3">
|
||||
<div className="border-t border-gray-100 pt-5 space-y-3" data-tour="device-preparation-validation">
|
||||
<p className="text-sm font-semibold text-gray-700 tracking-wide">Windows Autopilot Device Preparation</p>
|
||||
<label className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
|
||||
@@ -15,6 +15,8 @@ interface DataManagementSectionProps {
|
||||
retentionCapDays?: number;
|
||||
onSave: () => Promise<void> | void;
|
||||
saving: boolean;
|
||||
/** Unsaved edits of this card: the Save bar pins itself and enables Save. Left out, Save stays available. */
|
||||
unsaved?: boolean;
|
||||
/** Read-only viewer (Operator): values visible but inert, no Save bar. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
@@ -28,6 +30,7 @@ export default function DataManagementSection({
|
||||
retentionCapDays = 90,
|
||||
onSave,
|
||||
saving,
|
||||
unsaved,
|
||||
readOnly = false,
|
||||
}: DataManagementSectionProps) {
|
||||
const isOverridden = dataRetentionDays === 0 || dataRetentionDays < 7 || dataRetentionDays > retentionCapDays;
|
||||
@@ -123,7 +126,7 @@ export default function DataManagementSection({
|
||||
</div>
|
||||
</ReadOnlyFieldset>
|
||||
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} unsaved={unsaved} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,8 @@ interface DiagnosticsSectionProps {
|
||||
unrestrictedMode?: boolean;
|
||||
onSave: () => Promise<void> | void;
|
||||
saving: boolean;
|
||||
/** Unsaved edits of this card: the Save bar pins itself and enables Save. Left out, Save stays available. */
|
||||
unsaved?: boolean;
|
||||
/** Read-only viewer (Operator): settings visible but inert, no Save bar. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
@@ -64,6 +66,7 @@ export default function DiagnosticsSection({
|
||||
unrestrictedMode = false,
|
||||
onSave,
|
||||
saving,
|
||||
unsaved,
|
||||
readOnly = false,
|
||||
}: DiagnosticsSectionProps) {
|
||||
const isHosted = diagnosticsUploadDestination === "Hosted";
|
||||
@@ -103,7 +106,7 @@ export default function DiagnosticsSection({
|
||||
{/* Destination selector — explicit admin choice between customer-controlled SAS
|
||||
(default, data stays in customer's storage) and hosted (opt-in, data leaves
|
||||
tenant boundary). Never silently flipped. */}
|
||||
<div data-testid="diagnostics-destination" className="border border-gray-200 rounded-lg p-3">
|
||||
<div data-testid="diagnostics-destination" data-tour="diagnostics-upload" className="border border-gray-200 rounded-lg p-3">
|
||||
<p className="text-gray-700 font-medium text-sm mb-2">Upload destination</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<label
|
||||
@@ -366,7 +369,7 @@ export default function DiagnosticsSection({
|
||||
</div>
|
||||
</ReadOnlyFieldset>
|
||||
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} unsaved={unsaved} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+13
-8
@@ -4,13 +4,15 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
import type { HardwareRejectedResponse } from "@/lib/generated/wire-types.generated";
|
||||
import { reportedValueEntry } from "@/lib/hardwareWhitelist";
|
||||
|
||||
|
||||
|
||||
interface HardwareRejectionInsightsProps {
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
onAddManufacturer?: (value: string) => void;
|
||||
onAddModel?: (value: string) => void;
|
||||
/** Receives the whitelist entry for a reported value (see reportedValueEntry), never the raw value. */
|
||||
onAddManufacturer?: (entry: string) => void;
|
||||
onAddModel?: (entry: string) => void;
|
||||
webhookNotifyOnHardwareRejection: boolean;
|
||||
onToggleNotification: (value: boolean) => void;
|
||||
hasWebhook: boolean;
|
||||
@@ -215,6 +217,9 @@ export default function HardwareRejectionInsights({
|
||||
{data!.aggregated.map((row) => {
|
||||
const rowKey = `${row.manufacturer}|${row.model}`;
|
||||
const isExpanded = expandedRow === rowKey;
|
||||
// Reported values arrive unauthenticated: one literal entry, or no button at all.
|
||||
const manufacturerEntry = reportedValueEntry(row.manufacturer);
|
||||
const modelEntry = reportedValueEntry(row.model);
|
||||
|
||||
return (
|
||||
<tr key={rowKey} className="group">
|
||||
@@ -257,20 +262,20 @@ export default function HardwareRejectionInsights({
|
||||
<td className="py-3 pr-4 text-gray-500 whitespace-nowrap">{formatDate(row.lastSeen)}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-1">
|
||||
{onAddManufacturer && row.manufacturer && (
|
||||
{onAddManufacturer && manufacturerEntry && (
|
||||
<button
|
||||
onClick={() => onAddManufacturer(row.manufacturer)}
|
||||
onClick={() => onAddManufacturer(manufacturerEntry)}
|
||||
className="px-2 py-1 text-xs bg-blue-50 text-blue-700 rounded hover:bg-blue-100 transition-colors whitespace-nowrap"
|
||||
title={`Add "${row.manufacturer}" to manufacturer whitelist`}
|
||||
title={`Add "${manufacturerEntry}" to manufacturer whitelist`}
|
||||
>
|
||||
+ Mfr
|
||||
</button>
|
||||
)}
|
||||
{onAddModel && row.model && (
|
||||
{onAddModel && modelEntry && (
|
||||
<button
|
||||
onClick={() => onAddModel(row.model)}
|
||||
onClick={() => onAddModel(modelEntry)}
|
||||
className="px-2 py-1 text-xs bg-indigo-50 text-indigo-700 rounded hover:bg-indigo-100 transition-colors whitespace-nowrap"
|
||||
title={`Add "${row.model}" to model whitelist`}
|
||||
title={`Add "${modelEntry}" to model whitelist`}
|
||||
>
|
||||
+ Model
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import SaveBar from "./SaveBar";
|
||||
import ReadOnlyFieldset from "./ReadOnlyFieldset";
|
||||
import { joinList, parseList } from "../lib/hardwareWhitelist";
|
||||
import { joinList, parseList, splitTypedWhitelistInput } from "@/lib/hardwareWhitelist";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
|
||||
@@ -14,6 +14,8 @@ interface HardwareWhitelistSectionProps {
|
||||
setModelWhitelist: (value: string) => void;
|
||||
onSave: () => Promise<void> | void;
|
||||
saving: boolean;
|
||||
/** Unsaved edits of this card: the Save bar pins itself and enables Save. Left out, Save stays available. */
|
||||
unsaved?: boolean;
|
||||
/** Read-only viewer (Operator): inputs disabled, no Save bar. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
@@ -33,9 +35,10 @@ function WhitelistEditor({
|
||||
}) {
|
||||
const [newItem, setNewItem] = useState("");
|
||||
// Admin-typed input is a LIST: "Dell*, *" adds two patterns (the placeholder invites
|
||||
// exactly that). Only the insights buttons neutralize ',' — their values are untrusted.
|
||||
// exactly that), but "VMware, Inc." stays one manufacturer (splitTypedWhitelistInput). The
|
||||
// insights buttons neutralize every ',' instead — their values are untrusted.
|
||||
const existing = new Set(items.map((a) => a.toLowerCase()));
|
||||
const typed = parseList(newItem);
|
||||
const typed = splitTypedWhitelistInput(newItem);
|
||||
const fresh = typed.filter((t, i) =>
|
||||
!existing.has(t.toLowerCase()) && typed.findIndex((u) => u.toLowerCase() === t.toLowerCase()) === i);
|
||||
const isDuplicate = typed.length > 0 && fresh.length === 0;
|
||||
@@ -129,6 +132,7 @@ export default function HardwareWhitelistSection({
|
||||
setModelWhitelist,
|
||||
onSave,
|
||||
saving,
|
||||
unsaved,
|
||||
readOnly = false,
|
||||
}: HardwareWhitelistSectionProps) {
|
||||
const manufacturers = parseList(manufacturerWhitelist);
|
||||
@@ -186,7 +190,7 @@ export default function HardwareWhitelistSection({
|
||||
</div>
|
||||
</ReadOnlyFieldset>
|
||||
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
|
||||
{!readOnly && <SaveBar onSave={onSave} saving={saving} unsaved={unsaved} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user