mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec025686ce | |||
| 821c41e2ba | |||
| ec3eaa6437 | |||
| d36c9a659d | |||
| c27dbf2aa2 | |||
| 28443d11fa | |||
| 0c41b0f449 | |||
| 7799fdb725 | |||
| 0c1a2a4192 | |||
| 0356770ae8 | |||
| 3a9a074be1 | |||
| a3870e7dcc |
+1
-1
Submodule .claude/commands updated: 2ace35ae7d...0d25a07655
+1
-1
Submodule internal updated: c2cf529c83...997079976b
+71
@@ -0,0 +1,71 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// The completion timer a started Hello wizard runs against is the tenant's Hello wait
|
||||
/// (<c>HelloWaitTimeoutSeconds</c>), resolved to 300..3600 s — the same budget the engine's
|
||||
/// hello_safety window spends from the ESP exit, so a tenant allowing an hour is not cut off
|
||||
/// by a fixed 5-min tracker timer while the user is still inside the wizard.
|
||||
/// </summary>
|
||||
public sealed class HelloTrackerCompletionBudgetTests
|
||||
{
|
||||
private static readonly DateTime Fixed = new DateTime(2026, 9, 29, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Theory]
|
||||
[InlineData(30, 300)] // the old default, still stored for many tenants → the built-in window
|
||||
[InlineData(0, 300)]
|
||||
[InlineData(300, 300)]
|
||||
[InlineData(1800, 1800)]
|
||||
[InlineData(3600, 3600)]
|
||||
[InlineData(86400, 3600)] // clamped to the ceiling
|
||||
public void Completion_budget_is_the_resolved_tenant_hello_wait(int configured, int expected)
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = NewTracker(tmp, new FakeSignalIngressSink(), configured);
|
||||
|
||||
Assert.Equal(expected, tracker.CompletionTimeoutSecondsForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Completion_timeout_event_reports_the_tenant_budget()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
using var tracker = NewTracker(tmp, ingress, helloWaitTimeoutSeconds: 2400);
|
||||
|
||||
tracker.NotifyHelloWizardStarted();
|
||||
Assert.True(tracker.IsCompletionTimerActiveForTest);
|
||||
tracker.TriggerCompletionTimeoutForTest();
|
||||
|
||||
var timeout = ingress.Posted.Single(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == Constants.EventTypes.HelloCompletionTimeout);
|
||||
var data = Assert.IsAssignableFrom<IDictionary<string, object>>(timeout.TypedPayload);
|
||||
Assert.Equal(2400, data["timeoutSeconds"]);
|
||||
Assert.Equal("timeout", tracker.HelloOutcome);
|
||||
}
|
||||
|
||||
private static HelloTracker NewTracker(TempDirectory tmp, FakeSignalIngressSink ingress, int helloWaitTimeoutSeconds) =>
|
||||
new HelloTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: new InformationalEventPost(ingress, new VirtualClock(Fixed)),
|
||||
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info),
|
||||
helloWaitTimeoutSeconds: helloWaitTimeoutSeconds);
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// A tenant-extended Hello wait runs up to 60 min — the default session_stalled threshold. While
|
||||
/// it is pending the session waits on a bounded timer that resolves it either way, so the stall
|
||||
/// probe holds the report back instead of flagging the session Stalled; once the wait is over
|
||||
/// and the session is still idle the report fires, and real activity drops it. The collector
|
||||
/// runs with no scan sources, so only the session_stalled decision is exercised.
|
||||
/// </summary>
|
||||
public sealed class StallProbeHelloWaitGuardTests
|
||||
{
|
||||
private static readonly DateTime Fixed = new DateTime(2026, 9, 29, 8, 0, 0, DateTimeKind.Utc);
|
||||
private const double IdleAtStallThreshold = 60;
|
||||
|
||||
[Fact]
|
||||
public void Session_stalled_is_held_back_while_the_hello_wait_is_pending()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var collector = NewCollector(tmp, ingress, helloWaitPending: () => true);
|
||||
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold + 1);
|
||||
|
||||
Assert.Equal(0, StalledCount(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Held_back_report_fires_once_the_hello_wait_is_over_and_the_session_is_still_idle()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var pending = true;
|
||||
var collector = NewCollector(tmp, ingress, helloWaitPending: () => pending);
|
||||
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
Assert.Equal(0, StalledCount(ingress));
|
||||
|
||||
// hello_safety resolved (a deadline fire is no activity, so no ResetProbes): the next
|
||||
// tick emits the held-back report — once.
|
||||
pending = false;
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold + 1);
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold + 2);
|
||||
|
||||
Assert.Equal(1, StalledCount(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Real_activity_drops_the_held_back_report()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var pending = true;
|
||||
var collector = NewCollector(tmp, ingress, helloWaitPending: () => pending);
|
||||
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
pending = false;
|
||||
collector.ResetProbes();
|
||||
collector.CheckAndRunProbes(1);
|
||||
Assert.Equal(0, StalledCount(ingress));
|
||||
|
||||
// A new idle window reaches the threshold again — the fire-once report is still unused.
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
Assert.Equal(1, StalledCount(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_a_hello_wait_probe_session_stalled_fires_as_before()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var collector = NewCollector(tmp, ingress, helloWaitPending: null);
|
||||
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
|
||||
Assert.Equal(1, StalledCount(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_throwing_hello_wait_probe_never_costs_the_stall_report()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var collector = NewCollector(tmp, ingress, helloWaitPending: () => throw new InvalidOperationException("probe down"));
|
||||
|
||||
collector.CheckAndRunProbes(IdleAtStallThreshold);
|
||||
|
||||
Assert.Equal(1, StalledCount(ingress));
|
||||
}
|
||||
|
||||
private static StallProbeCollector NewCollector(TempDirectory tmp, FakeSignalIngressSink ingress, Func<bool>? helloWaitPending) =>
|
||||
new StallProbeCollector(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: new InformationalEventPost(ingress, new VirtualClock(Fixed)),
|
||||
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info),
|
||||
thresholdsMinutes: new[] { 60 },
|
||||
traceIndices: Array.Empty<int>(),
|
||||
sources: Array.Empty<string>(),
|
||||
sessionStalledAfterProbeIndex: 1,
|
||||
helloWaitPending: helloWaitPending);
|
||||
|
||||
private static int StalledCount(FakeSignalIngressSink ingress) =>
|
||||
ingress.Posted.Count(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == Constants.EventTypes.SessionStalled);
|
||||
}
|
||||
}
|
||||
+32
-3
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var logger = NewLogger(tmp.Path);
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger);
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, HelloWaitTimeout.DefaultSeconds, logger);
|
||||
|
||||
Assert.Single(sink.Posted);
|
||||
var posted = sink.Posted[0];
|
||||
@@ -47,7 +48,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var logger = NewLogger(tmp.Path);
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger);
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, HelloWaitTimeout.DefaultSeconds, logger);
|
||||
|
||||
var payload = sink.Posted[0].Payload;
|
||||
Assert.NotNull(payload);
|
||||
@@ -73,6 +74,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.True(cloudPc == "true" || cloudPc == "false");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(300)] // the default — the payload stays exactly what it was before the key existed
|
||||
[InlineData(30)] // the old default, still stored for many tenants
|
||||
[InlineData(0)]
|
||||
public void Hello_wait_not_extending_the_built_in_window_is_not_stamped(int helloWaitTimeoutSeconds)
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sink = new FakeSignalIngressSink();
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, helloWaitTimeoutSeconds, NewLogger(tmp.Path));
|
||||
|
||||
Assert.DoesNotContain(SignalPayloadKeys.HelloWaitTimeoutSeconds, (IDictionary<string, string>)sink.Posted[0].Payload!);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1800, "1800")]
|
||||
[InlineData(3600, "3600")]
|
||||
[InlineData(86400, "3600")] // the effective (clamped) value is what the engine should arm
|
||||
public void Extended_hello_wait_is_stamped_as_its_effective_value(int helloWaitTimeoutSeconds, string expected)
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sink = new FakeSignalIngressSink();
|
||||
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, helloWaitTimeoutSeconds, NewLogger(tmp.Path));
|
||||
|
||||
Assert.Equal(expected, sink.Posted[0].Payload![SignalPayloadKeys.HelloWaitTimeoutSeconds]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Swallows_sink_exceptions()
|
||||
{
|
||||
@@ -82,7 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
var logger = NewLogger(tmp.Path);
|
||||
|
||||
var ex = Record.Exception(() =>
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger));
|
||||
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, HelloWaitTimeout.DefaultSeconds, logger));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
@@ -157,12 +157,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
public double ReplaySpeedFactor { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Wait time in seconds after ESP exit before marking Hello as skipped.
|
||||
/// When ESP exits, we wait this duration for Hello wizard to start (event 62404).
|
||||
/// Default: 30 seconds (reasonable for systems under load).
|
||||
/// If Hello wizard starts within this window, we continue waiting for Hello completion (300/301).
|
||||
/// Tenant Hello wait in seconds after the ESP exit, merged from RemoteConfig. Stamped on
|
||||
/// <c>EnrollmentFactsObserved</c> for the decision engine's hello_safety window; the Hello
|
||||
/// tracker gets the same remote value through the collector configuration. Both resolve it
|
||||
/// through <see cref="HelloWaitTimeout.EffectiveSeconds"/> (at least the built-in 300 s,
|
||||
/// at most 3600 s). Default: 300 seconds.
|
||||
/// </summary>
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = 30;
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = HelloWaitTimeout.DefaultSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum consecutive authentication failures (401/403) before the agent shuts down.
|
||||
|
||||
+1
-1
@@ -241,7 +241,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
int helloWaitTimeoutSeconds = 30,
|
||||
int helloWaitTimeoutSeconds = HelloWaitTimeout.DefaultSeconds,
|
||||
bool modernDeploymentWatcherEnabled = true,
|
||||
int modernDeploymentLogLevelMax = 3,
|
||||
bool modernDeploymentBackfillEnabled = true,
|
||||
|
||||
+16
-9
@@ -47,7 +47,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
internal const string HResult_UserSkippedHello = "0x801C044F";
|
||||
|
||||
internal const int HelloCompletionTimeoutSeconds = 300;
|
||||
internal const int BackfillLookbackMinutes = 5;
|
||||
|
||||
private static readonly HashSet<int> TrackedUdrEventIds = new HashSet<int>
|
||||
@@ -81,6 +80,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly int _helloWaitTimeoutSeconds;
|
||||
|
||||
// How long a started Hello wizard may take to reach a terminal event (300/301/362): the
|
||||
// tenant's Hello wait resolved through HelloWaitTimeout (300..3600 s). The engine's
|
||||
// hello_safety window spends the same budget from the ESP exit, so this timer never cuts
|
||||
// a user off inside the wizard before the tenant's budget is used up.
|
||||
private readonly int _helloCompletionTimeoutSeconds;
|
||||
|
||||
private EventLogWatcher _udrWatcher;
|
||||
private EventLogWatcher _helloForBusinessWatcher;
|
||||
private System.Threading.Timer _policyCheckTimer;
|
||||
@@ -121,13 +126,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
int helloWaitTimeoutSeconds = 30)
|
||||
int helloWaitTimeoutSeconds = HelloWaitTimeout.DefaultSeconds)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_helloWaitTimeoutSeconds = helloWaitTimeoutSeconds;
|
||||
_helloCompletionTimeoutSeconds = HelloWaitTimeout.EffectiveSeconds(helloWaitTimeoutSeconds);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -1094,7 +1100,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (policyKnownEnabled)
|
||||
{
|
||||
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " +
|
||||
$"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)");
|
||||
$"wizard not yet visible, starting long completion timer ({_helloCompletionTimeoutSeconds}s)");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
@@ -1105,7 +1111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " +
|
||||
$"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard",
|
||||
$"Hello policy is enabled, waiting up to {_helloCompletionTimeoutSeconds}s for wizard",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
|
||||
@@ -1205,11 +1211,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (_isHelloCompleted) return;
|
||||
if (_helloCompletionTimer != null) return;
|
||||
|
||||
_logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
|
||||
_logger.Info($"Starting Hello completion timer ({_helloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
|
||||
_helloCompletionTimer = new System.Threading.Timer(
|
||||
OnHelloCompletionTimeout,
|
||||
null,
|
||||
TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds),
|
||||
TimeSpan.FromSeconds(_helloCompletionTimeoutSeconds),
|
||||
TimeSpan.FromMilliseconds(-1));
|
||||
}
|
||||
|
||||
@@ -1230,7 +1236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
|
||||
_logger.Warning($"Hello completion timeout ({_helloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
|
||||
_isHelloCompleted = true;
|
||||
HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started";
|
||||
StopHelloCompletionTimerLocked();
|
||||
@@ -1243,10 +1249,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s",
|
||||
Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {_helloCompletionTimeoutSeconds}s",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "timeoutSeconds", HelloCompletionTimeoutSeconds },
|
||||
{ "timeoutSeconds", _helloCompletionTimeoutSeconds },
|
||||
{ "helloWizardStarted", _helloWizardStarted }
|
||||
},
|
||||
ImmediateUpload = true
|
||||
@@ -1304,6 +1310,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal void TriggerWaitTimeoutForTest() => OnHelloWaitTimeout(null);
|
||||
internal void TriggerCompletionTimeoutForTest() => OnHelloCompletionTimeout(null);
|
||||
|
||||
internal int CompletionTimeoutSecondsForTest => _helloCompletionTimeoutSeconds;
|
||||
internal bool IsWaitTimerActiveForTest { get { lock (_stateLock) { return _helloWaitTimer != null; } } }
|
||||
internal bool IsCompletionTimerActiveForTest { get { lock (_stateLock) { return _helloCompletionTimer != null; } } }
|
||||
internal bool IsHelloWizardStartedForTest { get { lock (_stateLock) { return _helloWizardStarted; } } }
|
||||
|
||||
+79
-4
@@ -39,7 +39,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// - Trace-indexed probes: emit a <c>stall_probe_check</c> Trace event on quiet/activeInstalls.
|
||||
/// - Non-trace probes: emit nothing when no anomaly is found (agent log only).
|
||||
/// - Anomaly found: always emit <c>stall_probe_result</c> Warning (plus EspFailureDetected if terminal).
|
||||
/// - Probe index configured as "session-stalled": emit fire-once <c>session_stalled</c> Warning.
|
||||
/// - Probe index configured as "session-stalled": emit fire-once <c>session_stalled</c> Warning
|
||||
/// (held back while a tenant-extended Hello wait is pending, emitted once it is over).
|
||||
/// </summary>
|
||||
public class StallProbeCollector
|
||||
{
|
||||
@@ -54,10 +55,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly HashSet<int> _harmlessModernDeploymentEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
|
||||
// True while a tenant-extended Hello wait (up to 60 min) is pending in the decision
|
||||
// engine. It is a bounded wait that resolves the session either way — not a stall — so
|
||||
// session_stalled is held back until it is over.
|
||||
private readonly Func<bool> _helloWaitPending;
|
||||
|
||||
private readonly object _stateLock = new object();
|
||||
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
|
||||
private bool _sessionStalledFired;
|
||||
|
||||
// The session_stalled report held back while the Hello wait was pending (the probe result
|
||||
// of that idle window). Emitted on a later tick once the wait is over and the session is
|
||||
// still idle; dropped by real activity (ResetProbes).
|
||||
private ProbeResult _deferredSessionStalled;
|
||||
|
||||
// P2 — monotonic generation, bumped by ResetProbes. RunProbe runs OUTSIDE _stateLock; if a
|
||||
// real-activity reset lands while a probe is in flight, the post-probe "mark fired" must be
|
||||
// dropped (its generation is stale) so the next idle period can fire that probe again.
|
||||
@@ -153,9 +164,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string[] sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[] harmlessModernDeploymentEventIds = null,
|
||||
bool isDevicePreparation = false)
|
||||
bool isDevicePreparation = false,
|
||||
Func<bool> helloWaitPending = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_helloWaitPending = helloWaitPending;
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -192,6 +205,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// Always bump the generation, even when no probes are currently marked fired — a
|
||||
// probe may be running right now (outside the lock) and must not re-mark itself.
|
||||
_probeGeneration++;
|
||||
_deferredSessionStalled = null;
|
||||
if (_firedProbeIndices.Count > 0)
|
||||
{
|
||||
_logger.Trace($"StallProbeCollector: resetting {_firedProbeIndices.Count} fired probes");
|
||||
@@ -206,6 +220,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
public void CheckAndRunProbes(double idleMinutes)
|
||||
{
|
||||
EmitDeferredSessionStalledIfDue(idleMinutes);
|
||||
|
||||
// Iterate thresholds from lowest to highest so probes fire in order.
|
||||
for (int i = 0; i < _thresholdsMinutes.Length; i++)
|
||||
{
|
||||
@@ -225,7 +241,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (alreadyFired)
|
||||
continue;
|
||||
|
||||
RunProbe(probeIndex, thresholdMinutes, idleMinutes);
|
||||
RunProbe(probeIndex, thresholdMinutes, idleMinutes, genAtStart);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
@@ -237,7 +253,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes)
|
||||
private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes, int generation)
|
||||
{
|
||||
var started = DateTime.UtcNow;
|
||||
var result = new ProbeResult { ProbeIndex = probeIndex, IdleMinutes = Math.Round(idleMinutes, 1) };
|
||||
@@ -293,8 +309,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
// session_stalled is fire-once across the whole session. It fires at the configured
|
||||
// probe index UNLESS activeInstalls is observed (activeInstalls counts as progress).
|
||||
// While a tenant Hello wait is pending it is held back, not dropped.
|
||||
if (probeIndex == _sessionStalledAfterProbeIndex && !hasActiveInstalls)
|
||||
{
|
||||
if (IsHelloWaitPending())
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
// P2: a reset that landed while this probe ran ended the idle window.
|
||||
if (!_sessionStalledFired && _probeGeneration == generation)
|
||||
_deferredSessionStalled = result;
|
||||
}
|
||||
_logger.Info($"StallProbeCollector: session_stalled held back at probe {probeIndex} — a tenant Hello wait is still pending");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_sessionStalledFired)
|
||||
@@ -305,6 +334,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the held-back session_stalled report once the Hello wait is over and the session
|
||||
/// is still idle (no ResetProbes since), with the idle time of this tick.
|
||||
/// </summary>
|
||||
private void EmitDeferredSessionStalledIfDue(double idleMinutes)
|
||||
{
|
||||
ProbeResult deferred;
|
||||
lock (_stateLock)
|
||||
{
|
||||
deferred = _deferredSessionStalled;
|
||||
if (deferred == null || _sessionStalledFired)
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsHelloWaitPending())
|
||||
return;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_sessionStalledFired || !ReferenceEquals(_deferredSessionStalled, deferred))
|
||||
return;
|
||||
_sessionStalledFired = true;
|
||||
_deferredSessionStalled = null;
|
||||
}
|
||||
|
||||
deferred.IdleMinutes = Math.Round(idleMinutes, 1);
|
||||
_logger.Info($"StallProbeCollector: Hello wait over, still idle at {deferred.IdleMinutes}min — emitting the held-back session_stalled");
|
||||
EmitSessionStalledEvent(deferred);
|
||||
}
|
||||
|
||||
private bool IsHelloWaitPending()
|
||||
{
|
||||
if (_helloWaitPending == null)
|
||||
return false;
|
||||
try
|
||||
{
|
||||
return _helloWaitPending();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failing probe must never cost the stall report — treat as not pending.
|
||||
_logger.Warning($"StallProbeCollector: Hello-wait probe threw, treating as not pending: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunWithTimeout(Action<ProbeResult> scan, int timeoutMs, string sourceName, ProbeResult result)
|
||||
{
|
||||
// Enforce a real hard timeout: run the source on a dedicated background thread and
|
||||
|
||||
@@ -479,6 +479,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
if (collectors.StallProbeEnabled)
|
||||
{
|
||||
// A pending tenant Hello wait (up to 60 min) is a bounded wait that resolves the
|
||||
// session either way — the probe holds session_stalled back until it is over.
|
||||
Func<bool>? helloWaitPending = null;
|
||||
if (decisionStateProbe != null)
|
||||
helloWaitPending = () => DecisionEngine.IsTenantHelloWaitPending(decisionStateProbe(), clock.UtcNow);
|
||||
|
||||
hosts.Add(new StallProbeHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
@@ -490,7 +496,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
sources: collectors.StallProbeSources,
|
||||
sessionStalledAfterProbeIndex: collectors.SessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
||||
isDevicePreparation: isDevicePreparation));
|
||||
isDevicePreparation: isDevicePreparation,
|
||||
helloWaitPending: helloWaitPending));
|
||||
}
|
||||
|
||||
// ESP policy-provider stall tripwire — always-on kernel host (no config gate, like
|
||||
|
||||
@@ -58,7 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
string[]? sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[]? harmlessModernDeploymentEventIds,
|
||||
bool isDevicePreparation = false)
|
||||
bool isDevicePreparation = false,
|
||||
Func<bool>? helloWaitPending = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -74,7 +75,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
sources: sources ?? new[] { "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" },
|
||||
sessionStalledAfterProbeIndex: sessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds,
|
||||
isDevicePreparation: isDevicePreparation);
|
||||
isDevicePreparation: isDevicePreparation,
|
||||
helloWaitPending: helloWaitPending);
|
||||
|
||||
_adapter = new StallProbeCollectorAdapter(_collector, ingress, clock);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
|
||||
@@ -526,7 +526,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// analytics. WhiteGlove Part-2 resume runs as a fresh Classic
|
||||
// enrollment after PR-A's archive-and-reset, so the engine state is
|
||||
// empty and needs the facts re-seeded — same as a first boot.
|
||||
LifecycleEmitters.PostEnrollmentFactsObserved(orchestrator.IngressSink, logger);
|
||||
LifecycleEmitters.PostEnrollmentFactsObserved(orchestrator.IngressSink, agentConfig.HelloWaitTimeoutSeconds, logger);
|
||||
|
||||
// V2 parity — post SessionStarted so the reducer establishes the session
|
||||
// anchor (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped only
|
||||
|
||||
@@ -314,7 +314,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> signal carrying the
|
||||
/// registry-derived enrollment facts (<c>enrollmentType</c> + <c>isHybridJoin</c>)
|
||||
/// so the reducer can seed <see cref="State.EnrollmentScenarioProfile"/> via the
|
||||
/// stage-agnostic <c>HandleEnrollmentFactsObservedV1</c> handler.
|
||||
/// stage-agnostic <c>HandleEnrollmentFactsObservedV1</c> handler — plus the tenant's
|
||||
/// Hello wait when it extends the engine's built-in hello_safety window.
|
||||
/// <para>
|
||||
/// Posted immediately before <see cref="PostSessionStarted"/> so the Inspector
|
||||
/// timeline reads naturally (facts → anchor) — but the reducer correctness does
|
||||
@@ -329,6 +330,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// </summary>
|
||||
public static void PostEnrollmentFactsObserved(
|
||||
ISignalIngressSink ingressSink,
|
||||
int helloWaitTimeoutSeconds,
|
||||
AgentLogger logger)
|
||||
{
|
||||
try
|
||||
@@ -352,6 +354,16 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
["isCloudPc"] = isCloudPc ? "true" : "false",
|
||||
};
|
||||
|
||||
// Tenant Hello wait (remote config, not a registry fact) — stamped only when it
|
||||
// widens the engine's built-in hello_safety window, so sessions on the default
|
||||
// post the same payload as before.
|
||||
var helloWaitSeconds = HelloWaitTimeout.EffectiveSeconds(helloWaitTimeoutSeconds);
|
||||
if (helloWaitSeconds > HelloWaitTimeout.BuiltInSeconds)
|
||||
{
|
||||
payload[SignalPayloadKeys.HelloWaitTimeoutSeconds] =
|
||||
helloWaitSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var evidence = new Evidence(
|
||||
kind: EvidenceKind.Synthetic,
|
||||
identifier: "enrollment_registry_facts_read",
|
||||
@@ -364,7 +376,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
evidence: evidence,
|
||||
payload: payload);
|
||||
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}).");
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}, helloWaitSeconds={helloWaitSeconds}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant-extendable Hello window. The agent stamps the tenant's <c>HelloWaitTimeoutSeconds</c>
|
||||
/// on <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> when it exceeds the built-in
|
||||
/// 300 s; every <see cref="DeadlineNames.HelloSafety"/> arm site then uses it, clamped to
|
||||
/// 3600 s. Without the key the window stays exactly the built-in 300 s.
|
||||
/// </summary>
|
||||
public sealed class HelloSafetyTenantWindowTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 29, 8, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime EspExit = T0.AddMinutes(6);
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 300)]
|
||||
[InlineData("1800", 1800)]
|
||||
[InlineData("301", 301)]
|
||||
[InlineData("3600", 3600)]
|
||||
[InlineData("86400", 3600)] // beyond the ceiling — clamped
|
||||
[InlineData("300", 300)] // not an extension — ignored (the agent never stamps it)
|
||||
[InlineData("120", 300)]
|
||||
public void Esp_exit_arms_hello_safety_with_the_tenant_window(string? helloWait, int expectedSeconds)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, state.Stage);
|
||||
var helloSafety = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
Assert.Equal(EspExit.AddSeconds(expectedSeconds), helloSafety.DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenant_window_is_recorded_set_once_with_its_source_ordinal()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit("1800"));
|
||||
|
||||
var fact = state.ScenarioObservations.HelloWaitTimeoutSeconds;
|
||||
Assert.NotNull(fact);
|
||||
Assert.Equal(1800, fact!.Value);
|
||||
Assert.Equal(1, fact.SourceSignalOrdinal);
|
||||
|
||||
// A later re-post (every agent start posts the facts again) never re-bases the window.
|
||||
var repost = engine.Reduce(state, Facts(20, EspExit.AddSeconds(30), "3600"));
|
||||
Assert.Equal(1800, repost.NewState.ScenarioObservations.HelloWaitTimeoutSeconds!.Value);
|
||||
Assert.Equal(1, repost.NewState.ScenarioObservations.HelloWaitTimeoutSeconds!.SourceSignalOrdinal);
|
||||
Assert.Empty(repost.Effects);
|
||||
Assert.Equal(
|
||||
EspExit.AddSeconds(1800),
|
||||
Assert.Single(repost.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_surfaces_the_tenant_window_only_when_recorded()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
|
||||
var extended = DecisionStateSignalCensus.Build(ReduceAll(engine, ClassicUntilEspExit("2700")));
|
||||
Assert.Contains("hello_wait_extended", extended.SignalsSeen);
|
||||
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(extended.SignalEvidence["helloWaitExtended"]);
|
||||
Assert.Equal(2700, evidence["seconds"]);
|
||||
|
||||
var builtIn = DecisionStateSignalCensus.Build(ReduceAll(engine, ClassicUntilEspExit(helloWait: null)));
|
||||
Assert.DoesNotContain("hello_wait_extended", builtIn.SignalsSeen);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abc")]
|
||||
[InlineData("")]
|
||||
[InlineData("0")]
|
||||
[InlineData("-5")]
|
||||
public void Malformed_tenant_window_is_ignored(string helloWait)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait));
|
||||
|
||||
Assert.Null(state.ScenarioObservations.HelloWaitTimeoutSeconds);
|
||||
Assert.Equal(
|
||||
EspExit.AddSeconds(300),
|
||||
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Late_tenant_window_extends_the_armed_hello_safety_from_its_original_anchor()
|
||||
{
|
||||
// A backfilled ESP exit armed the built-in window before the facts signal landed.
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
|
||||
Assert.Equal(
|
||||
EspExit.AddSeconds(300),
|
||||
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
|
||||
var step = engine.Reduce(state, Facts(20, EspExit.AddSeconds(10), "1800"));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
var extended = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
Assert.Equal(EspExit.AddSeconds(1800), extended.DueAtUtc);
|
||||
Assert.Equal(DecisionSignalKind.DeadlineFired, extended.FiresSignalKind);
|
||||
Assert.Equal(DeadlineNames.HelloSafety, extended.FiresPayload![SignalPayloadKeys.Deadline]);
|
||||
|
||||
// The live scheduler is told to replace its timer (same name → replaced).
|
||||
var effect = Assert.Single(step.Effects);
|
||||
Assert.Equal(DecisionEffectKind.ScheduleDeadline, effect.Kind);
|
||||
Assert.Equal(EspExit.AddSeconds(1800), effect.Deadline!.DueAtUtc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("300")]
|
||||
[InlineData("120")]
|
||||
public void Late_tenant_window_not_longer_than_the_built_in_leaves_the_deadline_alone(string helloWait)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
|
||||
|
||||
var step = engine.Reduce(state, Facts(20, EspExit.AddSeconds(10), helloWait));
|
||||
|
||||
Assert.Empty(step.Effects);
|
||||
Assert.Null(step.NewState.ScenarioObservations.HelloWaitTimeoutSeconds);
|
||||
Assert.Equal(
|
||||
EspExit.AddSeconds(300),
|
||||
Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fire_of_the_replaced_built_in_deadline_is_dead_ended_after_the_late_extension()
|
||||
{
|
||||
// The old timer fired while the facts signal was still queued ahead of it: the facts
|
||||
// step moves hello_safety out, then the queued fire of the replaced incarnation
|
||||
// arrives. It must not time Hello out at the built-in window.
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
|
||||
var builtInDue = EspExit.AddSeconds(300);
|
||||
state = engine.Reduce(state, Facts(20, EspExit.AddSeconds(10), "1800")).NewState;
|
||||
|
||||
var stale = engine.Reduce(state, MakeSignal(21, DecisionSignalKind.DeadlineFired, builtInDue.AddSeconds(2),
|
||||
Fired(DeadlineNames.HelloSafety, builtInDue)));
|
||||
|
||||
Assert.False(stale.Transition.Taken);
|
||||
Assert.Equal("hello_safety_stale_superseded_by_rearm", stale.Transition.DeadEndReason);
|
||||
Assert.Empty(stale.Effects);
|
||||
Assert.Null(stale.NewState.HelloResolvedUtc);
|
||||
Assert.Equal(SessionStage.AwaitingHello, stale.NewState.Stage);
|
||||
var extendedDue = EspExit.AddSeconds(1800);
|
||||
Assert.Equal(
|
||||
extendedDue,
|
||||
Assert.Single(stale.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
|
||||
// The fire of the current incarnation still times Hello out (desktop is in → Finalizing).
|
||||
var current = engine.Reduce(stale.NewState, MakeSignal(22, DecisionSignalKind.DeadlineFired, extendedDue,
|
||||
Fired(DeadlineNames.HelloSafety, extendedDue)));
|
||||
Assert.Equal(SessionStage.Finalizing, current.NewState.Stage);
|
||||
Assert.Equal("Timeout", current.NewState.HelloOutcome!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Late_tenant_window_after_the_built_in_expired_leaves_the_pending_fire_to_decide()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
|
||||
|
||||
var step = engine.Reduce(state, Facts(20, EspExit.AddSeconds(301), "1800"));
|
||||
|
||||
Assert.Empty(step.Effects);
|
||||
Assert.Equal(
|
||||
EspExit.AddSeconds(300),
|
||||
Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
// The fact is still recorded — later arm sites use it.
|
||||
Assert.Equal(1800, step.NewState.ScenarioObservations.HelloWaitTimeoutSeconds!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Late_extension_is_replay_deterministic()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var signals = ClassicUntilEspExit(helloWait: null)
|
||||
.Concat(new[] { Facts(20, EspExit.AddSeconds(10), "2400") })
|
||||
.ToList();
|
||||
|
||||
var stepwise = ReduceAll(engine, signals);
|
||||
var replayed = ReducerReplay.Replay(engine, Seed(), signals);
|
||||
|
||||
Assert.Equal(
|
||||
Assert.Single(stepwise.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc,
|
||||
Assert.Single(replayed.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
Assert.Equal(EspExit.AddSeconds(2400), replayed.Deadlines.Single(d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenant_window_also_applies_to_the_AccountSetup_complete_deferred_promote()
|
||||
{
|
||||
// Strong gate arrives after the final exit and before the desktop: the deferred
|
||||
// promote arms hello_safety at the gate signal — with the tenant window.
|
||||
var engine = new DecisionEngine();
|
||||
var state = ReduceAll(engine, new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0),
|
||||
Facts(1, T0.AddSeconds(5), "2400"),
|
||||
MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), Phase("DeviceSetup")),
|
||||
MakeSignal(3, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1).AddSeconds(5),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "true" }),
|
||||
MakeSignal(4, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(12), Phase("AccountSetup")),
|
||||
MakeSignal(5, DecisionSignalKind.EspExiting, T0.AddMinutes(24)),
|
||||
});
|
||||
Assert.Equal(SessionStage.EspAccountSetup, state.Stage);
|
||||
|
||||
var gate = T0.AddMinutes(25);
|
||||
var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.AccountSetupProvisioningComplete, gate));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
Assert.Equal(
|
||||
gate.AddSeconds(2400),
|
||||
Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30 * 60)]
|
||||
[InlineData("1200", 30 * 60)] // a shorter tenant window never shortens the WDP backstop
|
||||
[InlineData("3600", 3600)]
|
||||
public void Device_preparation_backstop_is_never_shorter_than_the_tenant_window(string? helloWait, int expectedSeconds)
|
||||
{
|
||||
// WDP has no ESP, so hello_safety never arms; the desktop-armed backstop is the last
|
||||
// net behind the tracker's Hello completion timer, which runs on the tenant budget.
|
||||
var engine = new DecisionEngine();
|
||||
var facts = new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v2",
|
||||
[SignalPayloadKeys.EnrollmentTypeDeterministic] = "true",
|
||||
};
|
||||
if (helloWait != null)
|
||||
facts[SignalPayloadKeys.HelloWaitTimeoutSeconds] = helloWait;
|
||||
var desktop = T0.AddMinutes(9);
|
||||
|
||||
var state = ReduceAll(engine, new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0),
|
||||
MakeSignal(1, DecisionSignalKind.EnrollmentFactsObserved, T0, facts),
|
||||
MakeSignal(2, DecisionSignalKind.DesktopArrived, desktop),
|
||||
});
|
||||
|
||||
Assert.Equal(
|
||||
desktop.AddSeconds(expectedSeconds),
|
||||
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion).DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTenantHelloWaitPending_is_true_only_while_an_extended_window_is_armed_and_not_yet_due()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var awaiting = ReduceAll(engine, ClassicUntilEspExit("1800"));
|
||||
var due = EspExit.AddSeconds(1800);
|
||||
|
||||
Assert.True(DecisionEngine.IsTenantHelloWaitPending(awaiting, EspExit.AddMinutes(1)));
|
||||
Assert.True(DecisionEngine.IsTenantHelloWaitPending(awaiting, due.AddSeconds(-1)));
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(awaiting, due));
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(awaiting, due.AddSeconds(1)));
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(null, EspExit));
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(Seed(), EspExit));
|
||||
|
||||
// The built-in window never counts: default tenants keep their stall reporting.
|
||||
var builtIn = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
|
||||
Assert.Contains(builtIn.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(builtIn, EspExit.AddMinutes(1)));
|
||||
|
||||
// Hello resolved → the deadline is cancelled → nothing pending any more.
|
||||
var resolved = engine.Reduce(awaiting, MakeSignal(20, DecisionSignalKind.HelloResolved, EspExit.AddMinutes(10),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "completed" })).NewState;
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(resolved, EspExit.AddMinutes(11)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTenantHelloWaitPending_covers_the_device_preparation_backstop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var desktop = T0.AddMinutes(9);
|
||||
var state = ReduceAll(engine, new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0),
|
||||
MakeSignal(1, DecisionSignalKind.EnrollmentFactsObserved, T0, new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v2",
|
||||
[SignalPayloadKeys.EnrollmentTypeDeterministic] = "true",
|
||||
[SignalPayloadKeys.HelloWaitTimeoutSeconds] = "3600",
|
||||
}),
|
||||
MakeSignal(2, DecisionSignalKind.DesktopArrived, desktop),
|
||||
});
|
||||
|
||||
Assert.True(DecisionEngine.IsTenantHelloWaitPending(state, desktop.AddMinutes(59)));
|
||||
Assert.False(DecisionEngine.IsTenantHelloWaitPending(state, desktop.AddMinutes(60)));
|
||||
}
|
||||
|
||||
// ============================================================ helpers
|
||||
|
||||
private static DecisionState Seed() => DecisionState.CreateInitial("sess-hello-window", "tenant", T0);
|
||||
|
||||
/// <summary>
|
||||
/// Classic user-driven flow up to the final ESP exit: strong gate and desktop in place,
|
||||
/// Hello policy unknown — so the exit promotes to AwaitingHello and arms hello_safety.
|
||||
/// </summary>
|
||||
private static List<DecisionSignal> ClassicUntilEspExit(string? helloWait)
|
||||
{
|
||||
var signals = new List<DecisionSignal> { MakeSignal(0, DecisionSignalKind.SessionStarted, T0) };
|
||||
if (helloWait != null)
|
||||
signals.Add(Facts(1, T0.AddSeconds(5), helloWait));
|
||||
signals.Add(MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), Phase("AccountSetup")));
|
||||
signals.Add(MakeSignal(3, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(2)));
|
||||
signals.Add(MakeSignal(4, DecisionSignalKind.DesktopArrived, T0.AddMinutes(3)));
|
||||
signals.Add(MakeSignal(5, DecisionSignalKind.EspExiting, EspExit));
|
||||
return signals;
|
||||
}
|
||||
|
||||
private static DecisionState ReduceAll(DecisionEngine engine, IEnumerable<DecisionSignal> signals)
|
||||
{
|
||||
var state = Seed();
|
||||
foreach (var signal in signals)
|
||||
state = engine.Reduce(state, signal).NewState;
|
||||
return state;
|
||||
}
|
||||
|
||||
private static DecisionSignal Facts(long ordinal, DateTime occurredAtUtc, string helloWait) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, occurredAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloWaitTimeoutSeconds] = helloWait });
|
||||
|
||||
private static Dictionary<string, string> Fired(string deadlineName, DateTime dueAtUtc) =>
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = deadlineName,
|
||||
[SignalPayloadKeys.DeadlineDueAtUtc] = dueAtUtc.ToString("O", CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> Phase(string phase) =>
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = phase };
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null) =>
|
||||
new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
|
||||
payload: payload);
|
||||
}
|
||||
}
|
||||
@@ -523,7 +523,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
.WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5)
|
||||
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6)
|
||||
.WithCloudPc(value: true, sourceSignalOrdinal: 7)
|
||||
.WithAccountSetupProgressAtWhiteGloveSuccess(value: false, sourceSignalOrdinal: 8))
|
||||
.WithAccountSetupProgressAtWhiteGloveSuccess(value: false, sourceSignalOrdinal: 8)
|
||||
.WithHelloWaitTimeoutSeconds(value: 1800, sourceSignalOrdinal: 9))
|
||||
.Build();
|
||||
|
||||
var json = StateSerializer.Serialize(state);
|
||||
@@ -541,6 +542,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.Equal(7, o.CloudPc!.SourceSignalOrdinal);
|
||||
Assert.False(o.AccountSetupProgressAtWhiteGloveSuccess!.Value);
|
||||
Assert.Equal(8, o.AccountSetupProgressAtWhiteGloveSuccess!.SourceSignalOrdinal);
|
||||
Assert.Equal(1800, o.HelloWaitTimeoutSeconds!.Value);
|
||||
Assert.Equal(9, o.HelloWaitTimeoutSeconds!.SourceSignalOrdinal);
|
||||
|
||||
// Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9).
|
||||
Assert.Null(o.WhiteGloveSealingPatternSeen);
|
||||
|
||||
@@ -584,17 +584,18 @@ public class DelegatedAdminServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameGroup_DoesNotInvalidateCache()
|
||||
public async Task UpdateGroup_DoesNotInvalidateCache()
|
||||
{
|
||||
var (svc, repo) = Build();
|
||||
ReturnsGroups(repo, Assignment(GroupId1));
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
repo.Setup(r => r.RenameTenantGroupAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.UpdateTenantGroupAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>())).ReturnsAsync(true);
|
||||
|
||||
await svc.GetScopeAsync(Id(Upn));
|
||||
await svc.RenameGroupAsync(GroupId1, "Renamed");
|
||||
await svc.GetScopeAsync(Id(Upn)); // name-only change → scope stays cached
|
||||
await svc.UpdateGroupAsync(GroupId1, "Renamed", "Service Desk");
|
||||
await svc.GetScopeAsync(Id(Upn)); // name/label change → scope stays cached
|
||||
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId1, "Renamed", "Service Desk"), Times.Once);
|
||||
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,4 +563,62 @@ public class DelegationSelfServiceTests
|
||||
Assert.False(group.Revocable);
|
||||
Assert.Equal(2, group.Assignees.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListManagers_TwoLabelledOperatorGroups_AreDistinguishable_DirectGrantsStayNeutral()
|
||||
{
|
||||
const string serviceGroupId = "0f1e2d3c4b5a69788796a5b4c3d2e1f0";
|
||||
const string careGroupId = "1a2b3c4d5e6f70819293a4b5c6d7e8f9";
|
||||
var h = Build();
|
||||
h.Repo.Setup(x => x.GetGroupIdsContainingTenantAsync(Customer)).ReturnsAsync(new List<string> { serviceGroupId, careGroupId });
|
||||
h.Repo.Setup(x => x.GetTenantGroupAsync(serviceGroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = serviceGroupId, Name = "Internal managed service tenants", CustomerLabel = "Contoso Managed Service Team",
|
||||
TenantIds = new List<string> { Customer },
|
||||
});
|
||||
h.Repo.Setup(x => x.GetTenantGroupAsync(careGroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = careGroupId, Name = "Internal care unit", CustomerLabel = " Contoso Customer Care Unit ",
|
||||
TenantIds = new List<string> { Customer },
|
||||
});
|
||||
h.Repo.Setup(x => x.GetDelegatedAssigneesAsync(Customer)).ReturnsAsync(new List<DelegatedAdminEntry>
|
||||
{
|
||||
new() { Upn = "ops@vendor.example", TenantId = Customer, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true, Status = Constants.DelegatedStatus.Active, GrantedAt = Now.AddDays(-10) },
|
||||
});
|
||||
|
||||
var managers = await h.Svc.ListManagersAsync(Customer);
|
||||
|
||||
Assert.Equal("Contoso Managed Service Team", managers.Single(m => m.GroupId == serviceGroupId).Name);
|
||||
Assert.Equal("Contoso Customer Care Unit", managers.Single(m => m.GroupId == careGroupId).Name);
|
||||
Assert.All(managers.Where(m => m.GroupId != null), m =>
|
||||
{
|
||||
Assert.Equal(DelegationSelfService.SourceOperator, m.Source);
|
||||
Assert.False(m.Revocable);
|
||||
Assert.DoesNotContain("Internal", m.Name, StringComparison.Ordinal);
|
||||
});
|
||||
// A direct grant has no group, hence no label — it keeps the neutral name.
|
||||
Assert.Equal(DelegationSelfService.OperatorLabel, managers.Single(m => m.GroupId == null).Name);
|
||||
}
|
||||
|
||||
// ── Customer-facing group name ──────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "Platform support")]
|
||||
[InlineData("", "Platform support")]
|
||||
[InlineData(" ", "Platform support")]
|
||||
[InlineData(" Contoso Service Desk ", "Contoso Service Desk")]
|
||||
public void CustomerNameOf_OperatorGroup_LabelElseNeutral_NeverTheInternalName(string? label, string expected)
|
||||
{
|
||||
var group = new TenantGroup { GroupId = "0f1e2d3c4b5a69788796a5b4c3d2e1f0", Name = "Internal name", CustomerLabel = label };
|
||||
|
||||
Assert.Equal(expected, DelegationSelfService.CustomerNameOf(group));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomerNameOf_OwnedGroup_IsItsName_LabelIgnored()
|
||||
{
|
||||
var group = new TenantGroup { GroupId = GroupId, Name = "partner.example", OwnerTenantId = Home, CustomerLabel = "Ignored" };
|
||||
|
||||
Assert.Equal("partner.example", DelegationSelfService.CustomerNameOf(group));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant Hello wait (<see cref="TenantConfiguration.HelloWaitTimeoutSeconds"/>): a changed value
|
||||
/// must lie in 30..3600 s. Like the retention cap, only a CHANGED value is checked, so a value
|
||||
/// stored before the rule existed never blocks an unrelated save or a backup revert (both
|
||||
/// validate the whole model).
|
||||
/// </summary>
|
||||
public class HelloWaitTimeoutConfigTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
[Theory]
|
||||
[InlineData(30)]
|
||||
[InlineData(300)]
|
||||
[InlineData(1800)]
|
||||
[InlineData(3600)]
|
||||
public void ValidateModel_accepts_the_supported_range(int seconds)
|
||||
{
|
||||
var candidate = TenantConfiguration.CreateDefault(TenantId);
|
||||
candidate.HelloWaitTimeoutSeconds = seconds;
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, TenantConfiguration.CreateDefault(TenantId), isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(29)]
|
||||
[InlineData(3601)]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void ValidateModel_rejects_a_changed_value_outside_the_range(int seconds)
|
||||
{
|
||||
var candidate = TenantConfiguration.CreateDefault(TenantId);
|
||||
candidate.HelloWaitTimeoutSeconds = seconds;
|
||||
|
||||
var error = TenantConfigValidation.ValidateModel(candidate, TenantConfiguration.CreateDefault(TenantId), isGlobalAdmin: true);
|
||||
|
||||
Assert.Equal("Hello wait timeout must be between 30 and 3600 seconds.", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_lets_an_unchanged_out_of_range_value_through()
|
||||
{
|
||||
// Written through the API before the range was validated — an unrelated save (or a
|
||||
// revert to a snapshot holding the same value) must not fail on it.
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.HelloWaitTimeoutSeconds = 86400;
|
||||
var candidate = TenantConfiguration.CreateDefault(TenantId);
|
||||
candidate.HelloWaitTimeoutSeconds = 86400;
|
||||
candidate.ContactEmail = "it@example.com";
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_is_the_built_in_five_minutes()
|
||||
{
|
||||
Assert.Equal(300, TenantConfiguration.CreateDefault(TenantId).HelloWaitTimeoutSeconds);
|
||||
Assert.Equal(300, new CollectorConfiguration().HelloWaitTimeoutSeconds);
|
||||
Assert.Equal(300, HelloWaitTimeout.EffectiveSeconds(HelloWaitTimeout.DefaultSeconds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stored_old_default_stays_valid_and_keeps_the_built_in_window()
|
||||
{
|
||||
// Every tenant that ever saved the agent settings page has the old 30 s default stored.
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.HelloWaitTimeoutSeconds = 30;
|
||||
var candidate = TenantConfiguration.CreateDefault(TenantId);
|
||||
candidate.HelloWaitTimeoutSeconds = 30;
|
||||
candidate.ContactEmail = "it@example.com";
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
Assert.Equal(300, HelloWaitTimeout.EffectiveSeconds(30));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(30, 300)]
|
||||
[InlineData(300, 300)]
|
||||
[InlineData(301, 301)]
|
||||
[InlineData(3600, 3600)]
|
||||
[InlineData(86400, 3600)]
|
||||
[InlineData(0, 300)]
|
||||
public void EffectiveSeconds_keeps_the_built_in_window_as_floor_and_caps_at_one_hour(int configured, int expected)
|
||||
{
|
||||
Assert.Equal(expected, HelloWaitTimeout.EffectiveSeconds(configured));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Row_without_the_column_reads_as_the_default()
|
||||
{
|
||||
var entity = TableConfigRepository.ConvertToTenantTableEntity(TenantConfiguration.CreateDefault(TenantId));
|
||||
entity.Remove("HelloWaitTimeoutSeconds");
|
||||
|
||||
Assert.Equal(300, TableConfigRepository.ConvertFromTenantTableEntity(entity).HelloWaitTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stored_value_is_read_verbatim()
|
||||
{
|
||||
var entity = TableConfigRepository.ConvertToTenantTableEntity(TenantConfiguration.CreateDefault(TenantId));
|
||||
entity["HelloWaitTimeoutSeconds"] = 30;
|
||||
|
||||
Assert.Equal(30, TableConfigRepository.ConvertFromTenantTableEntity(entity).HelloWaitTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configured_value_roundtrips_store_and_map()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.HelloWaitTimeoutSeconds = 2400;
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.Equal(2400, mapped.HelloWaitTimeoutSeconds);
|
||||
}
|
||||
}
|
||||
@@ -373,4 +373,106 @@ public class RevokeEnforcementTests
|
||||
|
||||
proConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// --- Customer-visible audit rows name the group as the customer sees it, never internally ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "Platform support")]
|
||||
[InlineData("Contoso Service Desk", "Contoso Service Desk")]
|
||||
public async Task UnassignCore_AuditNamesTheGroupByItsCustomerName(string? label, string expected)
|
||||
{
|
||||
var (fn, repo, audit, _) = BuildGroupFn();
|
||||
repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = GroupId,
|
||||
Name = "Internal MSP customers north",
|
||||
CustomerLabel = label,
|
||||
TenantIds = new List<string> { TenantA },
|
||||
AssigneeCount = 1,
|
||||
});
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(UpnLower))
|
||||
.ReturnsAsync(new List<TenantGroupAssignment> { new() { Upn = UpnLower, GroupId = GroupId } });
|
||||
repo.Setup(r => r.UnassignGroupAsync(UpnLower, GroupId)).ReturnsAsync(true);
|
||||
|
||||
await fn.UnassignCoreAsync(GroupId, Upn, "ga@vendor.example");
|
||||
|
||||
audit.Verify(a => a.LogAuditEntryAsync(
|
||||
TenantA, "DELETE", "DelegatedGroupAccess", UpnLower, "ga@vendor.example",
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["Group"] == expected && d["GroupId"] == GroupId)), Times.Once);
|
||||
}
|
||||
|
||||
// --- Group update: name and/or customer label (PATCH only what changes) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, "name or customerLabel is required")]
|
||||
[InlineData(" ", null, "name must not be empty")]
|
||||
[InlineData("Renamed", null, null)]
|
||||
[InlineData(null, "", null)] // clears the label
|
||||
[InlineData(null, "Contoso Service Desk", null)]
|
||||
public void ValidateUpdateRequest_Shapes(string? name, string? label, string? expectedError)
|
||||
=> Assert.Equal(expectedError,
|
||||
TenantGroupManagementFunction.ValidateUpdateRequest(new UpdateTenantGroupRequest { Name = name, CustomerLabel = label }));
|
||||
|
||||
[Fact]
|
||||
public void ValidateUpdateRequest_LabelLongerThanCap_Rejected_TrimmedLengthCounts()
|
||||
{
|
||||
var atCap = " " + new string('x', DelegationSelfService.CustomerLabelMaxLength) + " ";
|
||||
Assert.Null(TenantGroupManagementFunction.ValidateUpdateRequest(new UpdateTenantGroupRequest { CustomerLabel = atCap }));
|
||||
|
||||
var tooLong = new string('x', DelegationSelfService.CustomerLabelMaxLength + 1);
|
||||
Assert.NotNull(TenantGroupManagementFunction.ValidateUpdateRequest(new UpdateTenantGroupRequest { CustomerLabel = tooLong }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGroupCore_Label_OnOperatorGroup_WritesOnlyTheLabel_NoAudit()
|
||||
{
|
||||
var (fn, repo, audit, _) = BuildGroupFn();
|
||||
repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync(new TenantGroup { GroupId = GroupId, Name = "MSP Customers" });
|
||||
repo.Setup(r => r.UpdateTenantGroupAsync(GroupId, null, "Contoso Service Desk")).ReturnsAsync(true);
|
||||
|
||||
var outcome = await fn.UpdateGroupCoreAsync(GroupId, new UpdateTenantGroupRequest { CustomerLabel = "Contoso Service Desk" }, "ga@vendor.example");
|
||||
|
||||
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.Updated, outcome);
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId, null, "Contoso Service Desk"), Times.Once);
|
||||
VerifyNoAudit(audit); // naming is not an access change
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGroupCore_Label_OnOwnedGroup_Refused_NothingWritten()
|
||||
{
|
||||
var (fn, repo, _, _) = BuildGroupFn();
|
||||
var ownedGroupId = Constants.TenantGroupIds.ForHomeTenant(OwnerTenant);
|
||||
repo.Setup(r => r.GetTenantGroupAsync(ownedGroupId)).ReturnsAsync(new TenantGroup { GroupId = ownedGroupId, Name = "partner.example", OwnerTenantId = OwnerTenant });
|
||||
|
||||
var outcome = await fn.UpdateGroupCoreAsync(ownedGroupId, new UpdateTenantGroupRequest { CustomerLabel = "Anything" }, "ga@vendor.example");
|
||||
|
||||
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.LabelOnOwnedGroup, outcome);
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGroupCore_Label_UnknownGroup_NotFound()
|
||||
{
|
||||
var (fn, repo, _, _) = BuildGroupFn(); // default: no group
|
||||
|
||||
var outcome = await fn.UpdateGroupCoreAsync(GroupId, new UpdateTenantGroupRequest { CustomerLabel = "Anything" }, "ga@vendor.example");
|
||||
|
||||
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.NotFound, outcome);
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGroupCore_RenameOnly_LeavesTheLabelAlone_MissIsNotFound()
|
||||
{
|
||||
var (fn, repo, _, _) = BuildGroupFn();
|
||||
repo.Setup(r => r.UpdateTenantGroupAsync(GroupId, "Renamed", null)).ReturnsAsync(true);
|
||||
|
||||
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.Updated,
|
||||
await fn.UpdateGroupCoreAsync(GroupId, new UpdateTenantGroupRequest { Name = "Renamed" }, "ga@vendor.example"));
|
||||
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId, "Renamed", null), Times.Once);
|
||||
|
||||
repo.Setup(r => r.UpdateTenantGroupAsync("tpl-missing", "Renamed", null)).ReturnsAsync(false);
|
||||
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.NotFound,
|
||||
await fn.UpdateGroupCoreAsync("tpl-missing", new UpdateTenantGroupRequest { Name = "Renamed" }, "ga@vendor.example"));
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -498,7 +498,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return groupIds;
|
||||
}
|
||||
|
||||
public async Task<bool> RenameTenantGroupAsync(string groupId, string name)
|
||||
public async Task<bool> UpdateTenantGroupAsync(string groupId, string? name, string? customerLabel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(groupId))
|
||||
return false;
|
||||
@@ -510,7 +510,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
var entity = result.Value;
|
||||
if (entity == null) return false;
|
||||
|
||||
entity.Name = name?.Trim() ?? string.Empty;
|
||||
if (name != null)
|
||||
entity.Name = name.Trim();
|
||||
if (customerLabel != null)
|
||||
entity.CustomerLabel = customerLabel.Trim();
|
||||
await _tenantGroupsTableClient.UpdateEntityAsync(entity, ETag.All);
|
||||
return true;
|
||||
}
|
||||
@@ -1022,6 +1025,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
group.CreatedBy = entity.CreatedBy;
|
||||
group.CreatedAt = entity.CreatedDate ?? default;
|
||||
group.OwnerTenantId = string.IsNullOrEmpty(entity.OwnerTenantId) ? null : entity.OwnerTenantId;
|
||||
group.CustomerLabel = string.IsNullOrEmpty(entity.CustomerLabel) ? null : entity.CustomerLabel;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -949,7 +949,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
EnrollmentSummaryTimeoutSeconds = entity.GetInt32("EnrollmentSummaryTimeoutSeconds"),
|
||||
EnrollmentSummaryBrandingImageUrl = entity.GetString("EnrollmentSummaryBrandingImageUrl"),
|
||||
EnrollmentSummaryLaunchRetrySeconds = entity.GetInt32("EnrollmentSummaryLaunchRetrySeconds"),
|
||||
HelloWaitTimeoutSeconds = entity.GetInt32("HelloWaitTimeoutSeconds") ?? 30,
|
||||
HelloWaitTimeoutSeconds = entity.GetInt32("HelloWaitTimeoutSeconds") ?? HelloWaitTimeout.DefaultSeconds,
|
||||
AgentMaxLifetimeMinutes = entity.GetInt32("AgentMaxLifetimeMinutes"),
|
||||
SendTraceEvents = entity.GetBoolean("SendTraceEvents") ?? true,
|
||||
EnableLocalAdminAnalyzer = entity.GetBoolean("EnableLocalAdminAnalyzer"),
|
||||
|
||||
+58
-13
@@ -23,8 +23,9 @@ namespace AutopilotMonitor.Functions.Functions.Admin;
|
||||
/// repository directly) so the delegated-scope cache is invalidated in lockstep.
|
||||
///
|
||||
/// Audit: access-affecting mutations are logged under the <b>affected managed tenant(s)</b>' trail (so the
|
||||
/// customer sees "who can read my tenant" / "access removed"). Group create/rename carry no tenant context
|
||||
/// and are not customer-visible access changes — they are logged operationally only (no AuditLogs partition).
|
||||
/// customer sees "who can read my tenant" / "access removed"); the group appears there under its customer-facing
|
||||
/// name (<see cref="DelegationSelfService.CustomerNameOf"/>), never its internal name. Group create/rename/label
|
||||
/// carry no access change — they are logged operationally only (no AuditLogs partition).
|
||||
/// </summary>
|
||||
public class TenantGroupManagementFunction
|
||||
{
|
||||
@@ -100,8 +101,11 @@ public class TenantGroupManagementFunction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PATCH /api/global/tenant-groups/{groupId} — rename a group. GlobalAdminOnly.
|
||||
/// Body: { "name": "..." }. A rename has no scope effect.
|
||||
/// PATCH /api/global/tenant-groups/{groupId} — rename a group and/or set the customer label of an operator
|
||||
/// group. GlobalAdminOnly. Body: { "name"?: "...", "customerLabel"?: "..." } (only the fields to change;
|
||||
/// an empty label clears it). Neither has a scope effect; the label only changes how the access is named to
|
||||
/// the member tenants (<see cref="DelegationSelfService.CustomerNameOf"/>), so it is logged operationally
|
||||
/// like a rename, not audited as an access change.
|
||||
/// </summary>
|
||||
[Function("UpdateTenantGroup")]
|
||||
[Authorize]
|
||||
@@ -114,14 +118,55 @@ public class TenantGroupManagementFunction
|
||||
var read = await req.ReadAsync<UpdateTenantGroupRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (string.IsNullOrWhiteSpace(body.Name))
|
||||
return await Bad(req, "name is required");
|
||||
var validationError = ValidateUpdateRequest(body);
|
||||
if (validationError != null)
|
||||
return await Bad(req, validationError);
|
||||
|
||||
if (!await _delegatedAdminService.RenameGroupAsync(groupId, body.Name))
|
||||
return await NotFound(req);
|
||||
_logger.LogInformation("Tenant group renamed: {GroupId} -> '{Name}' by {By}", groupId, body.Name, currentUpn);
|
||||
return await UpdateGroupCoreAsync(groupId, body, currentUpn) switch
|
||||
{
|
||||
GroupUpdateOutcome.NotFound => await NotFound(req),
|
||||
GroupUpdateOutcome.LabelOnOwnedGroup => await req.ConflictAsync(
|
||||
"A self-service group has no customer label: its customers see the managing organization's name."),
|
||||
_ => await req.OkAsync(new MessageResponse { Message = "Group updated" }),
|
||||
};
|
||||
}
|
||||
|
||||
return await req.OkAsync(new MessageResponse { Message = "Group updated" });
|
||||
/// <summary>Outcome of <see cref="UpdateGroupCoreAsync"/>.</summary>
|
||||
internal enum GroupUpdateOutcome { Updated, NotFound, LabelOnOwnedGroup }
|
||||
|
||||
/// <summary>Shape check of a PATCH body (400 message, or null when valid): at least one field, no blank name, a bounded label.</summary>
|
||||
internal static string? ValidateUpdateRequest(UpdateTenantGroupRequest body)
|
||||
{
|
||||
if (body.Name == null && body.CustomerLabel == null)
|
||||
return "name or customerLabel is required";
|
||||
if (body.Name != null && string.IsNullOrWhiteSpace(body.Name))
|
||||
return "name must not be empty";
|
||||
if (body.CustomerLabel != null && body.CustomerLabel.Trim().Length > DelegationSelfService.CustomerLabelMaxLength)
|
||||
return $"customerLabel must be at most {DelegationSelfService.CustomerLabelMaxLength} characters";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Testable core of the group update (body already validated). A customer label is refused on an OWNED
|
||||
/// (self-service) group — its customers see the managing tenant's name, so a label there would be dead data.
|
||||
/// </summary>
|
||||
internal async Task<GroupUpdateOutcome> UpdateGroupCoreAsync(string groupId, UpdateTenantGroupRequest body, string? currentUpn)
|
||||
{
|
||||
if (body.CustomerLabel != null)
|
||||
{
|
||||
var group = await _delegatedAdminService.GetGroupAsync(groupId);
|
||||
if (group == null)
|
||||
return GroupUpdateOutcome.NotFound;
|
||||
if (!string.IsNullOrWhiteSpace(group.OwnerTenantId))
|
||||
return GroupUpdateOutcome.LabelOnOwnedGroup;
|
||||
}
|
||||
|
||||
if (!await _delegatedAdminService.UpdateGroupAsync(groupId, body.Name, body.CustomerLabel))
|
||||
return GroupUpdateOutcome.NotFound;
|
||||
|
||||
_logger.LogInformation("Tenant group updated: {GroupId} name='{Name}' customerLabel='{CustomerLabel}' by {By}",
|
||||
groupId, body.Name, body.CustomerLabel, currentUpn);
|
||||
return GroupUpdateOutcome.Updated;
|
||||
}
|
||||
|
||||
/// <summary>DELETE /api/global/tenant-groups/{groupId} — delete group + all its assignments. GlobalAdminOnly.</summary>
|
||||
@@ -173,7 +218,7 @@ public class TenantGroupManagementFunction
|
||||
await AuditPerTenantAsync(group.TenantIds, "DELETE", "*", currentUpn,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "Group", group.Name },
|
||||
{ "Group", DelegationSelfService.CustomerNameOf(group) },
|
||||
{ "GroupId", groupId },
|
||||
{ "Reason", "group-deleted" },
|
||||
{ "AssigneeCount", group.AssigneeCount.ToString() },
|
||||
@@ -335,7 +380,7 @@ public class TenantGroupManagementFunction
|
||||
await AuditPerTenantAsync(group.TenantIds, "CREATE", upn, currentUpn,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "Group", group.Name },
|
||||
{ "Group", DelegationSelfService.CustomerNameOf(group) },
|
||||
{ "GroupId", groupId },
|
||||
{ "Role", role },
|
||||
{ "Reason", "group-assigned" },
|
||||
@@ -384,7 +429,7 @@ public class TenantGroupManagementFunction
|
||||
await AuditPerTenantAsync(group.TenantIds, "DELETE", normalizedUpn, currentUpn,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "Group", group.Name },
|
||||
{ "Group", DelegationSelfService.CustomerNameOf(group) },
|
||||
{ "GroupId", groupId },
|
||||
{ "Reason", "group-unassigned" },
|
||||
});
|
||||
|
||||
@@ -161,6 +161,13 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
&& (registrations < 1 || registrations > Services.McpClientRegistrationService.MaxRegistrationLimit))
|
||||
return $"Self-hosted AI client registrations must be between 1 and {Services.McpClientRegistrationService.MaxRegistrationLimit} (or left blank for the default of {Services.McpClientRegistrationService.DefaultRegistrationLimit}).";
|
||||
|
||||
// Only a CHANGED value is checked (same rule as the retention cap below): a stored value
|
||||
// written before this rule existed must not block unrelated saves or backup reverts.
|
||||
if (candidate.HelloWaitTimeoutSeconds != existing.HelloWaitTimeoutSeconds
|
||||
&& (candidate.HelloWaitTimeoutSeconds < HelloWaitTimeout.MinSeconds
|
||||
|| candidate.HelloWaitTimeoutSeconds > HelloWaitTimeout.MaxSeconds))
|
||||
return $"Hello wait timeout must be between {HelloWaitTimeout.MinSeconds} and {HelloWaitTimeout.MaxSeconds} seconds.";
|
||||
|
||||
var contactEmailError = ValidateContactEmail(candidate.ContactEmail);
|
||||
if (contactEmailError != null)
|
||||
return $"Invalid contact email: {contactEmailError}";
|
||||
|
||||
@@ -268,9 +268,9 @@ public class DelegatedAdminService
|
||||
public Task<string> CreateGroupAsync(string name, string createdBy)
|
||||
=> _adminRepo.CreateTenantGroupAsync(name, createdBy);
|
||||
|
||||
/// <summary>Renames a group (name only — no scope effect, no invalidation needed).</summary>
|
||||
public Task<bool> RenameGroupAsync(string groupId, string name)
|
||||
=> _adminRepo.RenameTenantGroupAsync(NormalizeGroupId(groupId), name);
|
||||
/// <summary>Renames a group and/or sets its customer label (null = unchanged) — no scope effect, no invalidation needed.</summary>
|
||||
public Task<bool> UpdateGroupAsync(string groupId, string? name, string? customerLabel)
|
||||
=> _adminRepo.UpdateTenantGroupAsync(NormalizeGroupId(groupId), name, customerLabel);
|
||||
|
||||
/// <summary>Creates a tenant-OWNED (self-service) group's meta row if missing — no scope effect (a fresh group has no members).</summary>
|
||||
public Task EnsureOwnedGroupAsync(string groupId, string name, string ownerTenantId)
|
||||
@@ -521,6 +521,9 @@ public class TenantGroupEntity : ITableEntity
|
||||
|
||||
/// <summary>Meta row only: the managing tenant that owns this self-service group (see <see cref="Constants.TenantGroupIds"/>); null on operator-created groups.</summary>
|
||||
public string? OwnerTenantId { get; set; }
|
||||
|
||||
/// <summary>Meta row only: the name a managed customer sees for an operator-created group (see <see cref="DelegationSelfService.CustomerNameOf"/>); null or empty when none is set.</summary>
|
||||
public string? CustomerLabel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -48,10 +48,13 @@ public class DelegationSelfService
|
||||
public const string SourceSelfService = "self-service";
|
||||
public const string SourceOperator = "operator";
|
||||
/// <summary>
|
||||
/// The one label a managed tenant sees for every operator-provisioned access (operator-created groups and
|
||||
/// direct grants alike). Operator groups are internal organisation — their names never reach a customer.
|
||||
/// The label a managed tenant sees for operator-provisioned access: direct grants, and operator-created groups
|
||||
/// without a <see cref="TenantGroup.CustomerLabel"/>. Operator groups are internal organisation — their names
|
||||
/// never reach a customer (D-267, D-300).
|
||||
/// </summary>
|
||||
public const string OperatorLabel = "Platform support";
|
||||
/// <summary>Longest customer label a Global Admin may set on an operator group.</summary>
|
||||
public const int CustomerLabelMaxLength = 80;
|
||||
private const string AuditGroupAccess = "DelegatedGroupAccess";
|
||||
private const string AuditInvitation = "DelegationInvitation";
|
||||
private const string AuditManagedTenant = "DelegationManagedTenant";
|
||||
@@ -382,11 +385,10 @@ public class DelegationSelfService
|
||||
.Select(r => r.AcceptedAt)
|
||||
.Max();
|
||||
}
|
||||
// An owned group carries its managing tenant's name; an operator group is internal and shows
|
||||
// only the neutral label (its assignees stay visible — the customer must know who can read it).
|
||||
// Its assignees stay visible either way — the customer must know who can read it.
|
||||
result.Add(owner != null
|
||||
? new TenantManagerView(group.GroupId, owner, await DomainAsync(owner), group.Name, SourceSelfService, group.Assignees, since, Revocable: true)
|
||||
: new TenantManagerView(group.GroupId, null, null, OperatorLabel, SourceOperator, group.Assignees, null, Revocable: false));
|
||||
? new TenantManagerView(group.GroupId, owner, await DomainAsync(owner), CustomerNameOf(group), SourceSelfService, group.Assignees, since, Revocable: true)
|
||||
: new TenantManagerView(group.GroupId, null, null, CustomerNameOf(group), SourceOperator, group.Assignees, null, Revocable: false));
|
||||
}
|
||||
|
||||
var direct = (await _adminRepo.GetDelegatedAssigneesAsync(target))
|
||||
@@ -401,6 +403,19 @@ public class DelegationSelfService
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The ONE place that decides how a group is named to a managed customer — the "Who can read this tenant"
|
||||
/// view and every audit row written under a member tenant use it. An owned (self-service) group carries its
|
||||
/// managing tenant's name; an operator group shows the customer label a Global Admin set for it, else the
|
||||
/// neutral <see cref="OperatorLabel"/>. The operator group's own name never reaches a customer.
|
||||
/// </summary>
|
||||
public static string CustomerNameOf(TenantGroup group)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(group.OwnerTenantId))
|
||||
return group.Name;
|
||||
return string.IsNullOrWhiteSpace(group.CustomerLabel) ? OperatorLabel : group.CustomerLabel.Trim();
|
||||
}
|
||||
|
||||
/// <summary>Decodes the ticket and loads the row. 400 for anything the link itself is wrong about.</summary>
|
||||
private async Task<DelegationResult<DelegationInvitation>> LocateAsync(string token)
|
||||
{
|
||||
|
||||
+4
-4
@@ -28,7 +28,7 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.4",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
@@ -3752,9 +3752,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.13",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
|
||||
"version": "4.23.15",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
|
||||
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.4",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
|
||||
@@ -1043,7 +1043,7 @@ export interface CollectorConfiguration {
|
||||
enableAgentSelfMetrics: boolean;
|
||||
/** Interval in seconds for agent self-metrics snapshots. Default: 60 seconds */
|
||||
agentSelfMetricsIntervalSeconds: number;
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit. Default: 30 seconds */
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds */
|
||||
helloWaitTimeoutSeconds: number;
|
||||
/** Maximum agent lifetime in minutes. Safety net to prevent zombie agents. 0 = disabled (no lifetime limit). Default: 360 (6 hours). */
|
||||
agentMaxLifetimeMinutes: number;
|
||||
@@ -4810,7 +4810,7 @@ export interface TenantConfiguration {
|
||||
enablePerformanceCollector: boolean;
|
||||
/** Performance collector interval in seconds Default: 30 seconds */
|
||||
performanceCollectorIntervalSeconds: number;
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit Default: 30 seconds */
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds */
|
||||
helloWaitTimeoutSeconds: number;
|
||||
/** Maximum consecutive authentication failures (401/403) before the agent shuts down. null = use default (5). 0 = disabled (retry forever). */
|
||||
maxAuthFailures?: number | null;
|
||||
@@ -5004,6 +5004,8 @@ export interface TenantGroup {
|
||||
assignees: TenantGroupAssignment[];
|
||||
/** The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups. */
|
||||
ownerTenantId?: string;
|
||||
/** Operator-created groups only: the name a managed customer sees for this access instead of the neutral "Platform support"; absent when none is set. The group name itself never reaches a customer. */
|
||||
customerLabel?: string;
|
||||
}
|
||||
|
||||
/** One UPN→group assignment. PK=UPN, RK=groupId in storage. */
|
||||
@@ -5036,7 +5038,7 @@ export interface TenantManagerItem {
|
||||
/** The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants. */
|
||||
ownerTenantId?: string;
|
||||
ownerDomain?: string;
|
||||
/** The owned group's name for a self-service delegation; the neutral operator label ("Platform support") for every operator-provisioned entry — internal group names never reach a customer. */
|
||||
/** The owned group's name for a self-service delegation; for an operator group its customer label, else the neutral "Platform support" (always for direct operator grants) — internal group names never reach a customer. */
|
||||
name: string;
|
||||
/** self-service | operator */
|
||||
source: string;
|
||||
@@ -5339,10 +5341,12 @@ export interface UpdateTenantAppHomingResponse {
|
||||
probe: AppHomingProbeWire;
|
||||
}
|
||||
|
||||
/** Body of PATCH global/tenant-groups/{groupId} — a rename. */
|
||||
/** Body of PATCH global/tenant-groups/{groupId} — only the fields to change; at least one is required. */
|
||||
export interface UpdateTenantGroupRequest {
|
||||
/** New display name. */
|
||||
/** New display name (Global Admin UI only). */
|
||||
name?: string | null;
|
||||
/** The name managed customers see for this operator group; an empty string clears it back to "Platform support". Rejected on self-service groups. */
|
||||
customerLabel?: string | null;
|
||||
}
|
||||
|
||||
/** Response of PUT sessions/{sessionId}/annotations/{lane} when both verdict and note were empty and the lane was cleared. */
|
||||
|
||||
@@ -12,7 +12,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
public static class DeadlineNames
|
||||
{
|
||||
/// <summary>Post-ESP-exit grace period for Hello resolution. Plan §2.7 (300 s).</summary>
|
||||
/// <summary>
|
||||
/// Post-ESP-exit grace period for Hello resolution. Plan §2.7 (300 s built in; a tenant
|
||||
/// can extend it to 3600 s via <c>HelloWaitTimeoutSeconds</c>).
|
||||
/// </summary>
|
||||
public const string HelloSafety = "hello_safety";
|
||||
|
||||
/// <summary>Brief settle window after ESP exit before we emit completion.</summary>
|
||||
@@ -190,6 +193,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
public const string IsCloudPc = "isCloudPc";
|
||||
|
||||
/// <summary>
|
||||
/// On <c>EnrollmentFactsObserved</c>: the tenant's Hello wait in seconds (remote config
|
||||
/// <c>HelloWaitTimeoutSeconds</c>, stamped by the agent — NOT a registry fact). Only
|
||||
/// stamped when it extends the built-in 300 s <see cref="DeadlineNames.HelloSafety"/>
|
||||
/// window; missing → the built-in window.
|
||||
/// </summary>
|
||||
public const string HelloWaitTimeoutSeconds = "helloWaitTimeoutSeconds";
|
||||
|
||||
// --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------
|
||||
// Mirrors the EnrollmentEvent fields the reducer must reconstruct for the
|
||||
// EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are
|
||||
|
||||
@@ -10,9 +10,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// Classic UserDriven-v1 enrollment handlers. Plan §2.5 partial-class layout.
|
||||
public sealed partial class DecisionEngine
|
||||
{
|
||||
// Post-ESP Hello-safety grace period per plan §2.7.
|
||||
private static readonly TimeSpan s_helloSafetyWindow = TimeSpan.FromSeconds(300);
|
||||
|
||||
// Grace window between both-prerequisites-resolved and Completed. Plan §5 Fix 6.
|
||||
// Short enough that the user doesn't notice; long enough for the reducer's
|
||||
// phase_transition(FinalizingSetup) + enrollment_complete effects to reach the
|
||||
@@ -267,18 +264,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
: null);
|
||||
}
|
||||
|
||||
// Replay-safety: floor the 300-s Hello-safety window at AgentBootUtc so a replayed
|
||||
// EspExiting signal from CMTrace doesn't fire HelloSafety immediately at boot
|
||||
// (which would mark Hello as Timeout the moment the agent reads the log tail).
|
||||
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var helloSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: dueAtUtc,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var helloSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
@@ -376,6 +362,89 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
return new DecisionStep(newState, transition, AppendEffect(effects, waitingEffect));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Post-ESP Hello-safety window (plan §2.7): the built-in 300 s, or the tenant's longer
|
||||
/// <c>HelloWaitTimeoutSeconds</c> once the agent stamped it
|
||||
/// (<see cref="EnrollmentScenarioObservations.HelloWaitTimeoutSeconds"/>), clamped to
|
||||
/// <see cref="HelloWaitTimeout.MaxSeconds"/>.
|
||||
/// </summary>
|
||||
internal static TimeSpan HelloSafetyWindow(EnrollmentScenarioObservations observations) =>
|
||||
TimeSpan.FromSeconds(HelloWaitTimeout.EffectiveSeconds(
|
||||
observations.HelloWaitTimeoutSeconds?.Value ?? HelloWaitTimeout.BuiltInSeconds));
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="DeadlineNames.HelloSafety"/> deadline every arm site schedules:
|
||||
/// <see cref="HelloSafetyWindow"/> after the signal, floored at the agent boot
|
||||
/// (<see cref="EffectiveDeadlineBase"/>) so a replayed EspExiting from CMTrace doesn't
|
||||
/// fire HelloSafety immediately at boot (which would mark Hello as Timeout the moment
|
||||
/// the agent reads the log tail).
|
||||
/// </summary>
|
||||
private static ActiveDeadline BuildHelloSafetyDeadline(DecisionState state, DecisionSignal signal) =>
|
||||
new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(HelloSafetyWindow(state.ScenarioObservations)),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// The tenant window arrived after <see cref="DeadlineNames.HelloSafety"/> was armed —
|
||||
/// necessarily with the built-in window, the fact being set-once. Moves the armed
|
||||
/// deadline out by the difference so the tenant's window counts from the original
|
||||
/// anchor, and returns the re-schedule effect (the live scheduler replaces a timer of
|
||||
/// the same name). <c>null</c> when nothing changes: the fact was already known, the
|
||||
/// window is not longer, no deadline is armed, or it had expired at this signal —
|
||||
/// then its pending fire decides.
|
||||
/// </summary>
|
||||
private static DecisionEffect? ExtendArmedHelloSafetyToTenantWindow(
|
||||
DecisionState before, DecisionStateBuilder builder, DecisionSignal signal)
|
||||
{
|
||||
if (before.ScenarioObservations.HelloWaitTimeoutSeconds != null)
|
||||
return null;
|
||||
|
||||
var extension = HelloSafetyWindow(builder.ScenarioObservations) - HelloSafetyWindow(before.ScenarioObservations);
|
||||
if (extension <= TimeSpan.Zero)
|
||||
return null;
|
||||
|
||||
foreach (var armed in before.Deadlines)
|
||||
{
|
||||
if (armed.Name != DeadlineNames.HelloSafety)
|
||||
continue;
|
||||
if (armed.DueAtUtc <= signal.OccurredAtUtc)
|
||||
return null;
|
||||
|
||||
var extended = new ActiveDeadline(
|
||||
armed.Name, armed.DueAtUtc.Add(extension), armed.FiresSignalKind, armed.FiresPayload);
|
||||
builder.AddDeadline(extended);
|
||||
return new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: extended);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> while a tenant-extended Hello wait is running: the tenant window is
|
||||
/// recorded and a Hello resolution deadline (<see cref="DeadlineNames.HelloSafety"/>, or
|
||||
/// the Device Preparation backstop that stands in for it) is armed and not yet due at
|
||||
/// <paramref name="nowUtc"/>. The session is then waiting on a bounded window (up to
|
||||
/// <see cref="HelloWaitTimeout.MaxSeconds"/>) that resolves it either way — not stalled.
|
||||
/// Always <c>false</c> on the built-in window, so default tenants keep their stall
|
||||
/// reporting unchanged. Read by the agent's stall probe.
|
||||
/// </summary>
|
||||
public static bool IsTenantHelloWaitPending(DecisionState? state, DateTime nowUtc)
|
||||
{
|
||||
if (state?.ScenarioObservations.HelloWaitTimeoutSeconds == null)
|
||||
return false;
|
||||
foreach (var d in state.Deadlines)
|
||||
{
|
||||
if ((d.Name == DeadlineNames.HelloSafety || d.Name == DeadlineNames.DevicePrepCompletion)
|
||||
&& d.DueAtUtc > nowUtc)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session 8b8d611d fix (2026-05-20): emit a <see cref="DecisionEffectKind.CancelDeadline"/>
|
||||
/// effect for <see cref="DeadlineNames.HelloSafety"/> when it is actually armed in
|
||||
@@ -469,15 +538,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
builder.CancelDeadline(DeadlineNames.FinalizingGrace);
|
||||
}
|
||||
|
||||
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var helloSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: dueAtUtc,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var helloSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
.AddDeadline(helloSafety);
|
||||
@@ -524,7 +585,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// finished AccountSetup) OR <see cref="EnrollmentScenarioObservations.SkipUserEsp"/>
|
||||
/// is <c>true</c> (no User-ESP page on this flow) — synthesise
|
||||
/// <c>HelloOutcome="Skipped"</c> here and route directly through Finalizing.
|
||||
/// This avoids waiting out the full 5-min HelloSafety window when the policy reader
|
||||
/// This avoids waiting out the full HelloSafety window when the policy reader
|
||||
/// already told us no Hello wizard is expected. Belt-and-suspenders alongside the
|
||||
/// EspExiting → HelloSafety reducer path: the path that fires first wins.
|
||||
/// </para>
|
||||
@@ -842,15 +903,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// AgentBootUtc for replay safety) — mirror of the deferred-promote tail in
|
||||
// HandleAccountSetupProvisioningCompleteV1. A real HelloResolved then completes
|
||||
// normally; a no-show resolves via HelloSafety's synthetic outcome.
|
||||
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var helloSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: dueAtUtc,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var helloSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
|
||||
+18
-6
@@ -20,13 +20,20 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// <summary>
|
||||
/// Resolution window from real-user desktop arrival on a Device Preparation session.
|
||||
/// Deliberately the same order of magnitude as <c>AdvisoryCompletion</c> (30 min),
|
||||
/// not <c>HelloSafety</c> (300 s): the user may legitimately still be walking
|
||||
/// through a configured Hello wizard, and the tracker's own Hello completion
|
||||
/// timers normally resolve long before this fires — it is the last net, not the
|
||||
/// expected path.
|
||||
/// not the built-in <c>HelloSafety</c> window (300 s): the user may legitimately still
|
||||
/// be walking through a configured Hello wizard, and with the built-in budget the
|
||||
/// tracker's own Hello completion timer normally resolves long before this fires — it
|
||||
/// is the last net, not the expected path. A tenant-extended Hello wait lengthens it
|
||||
/// (see <see cref="BuildDevicePrepCompletionDeadline"/>).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan s_devicePrepCompletionWindow = TimeSpan.FromMinutes(30);
|
||||
|
||||
private static TimeSpan DevicePrepCompletionWindow(EnrollmentScenarioObservations observations)
|
||||
{
|
||||
var helloWindow = HelloSafetyWindow(observations);
|
||||
return helloWindow > s_devicePrepCompletionWindow ? helloWindow : s_devicePrepCompletionWindow;
|
||||
}
|
||||
|
||||
/// <summary>True when <see cref="DeadlineNames.DevicePrepCompletion"/> is armed in <paramref name="state"/>.</summary>
|
||||
private static bool HasDevicePrepCompletionDeadline(DecisionState state)
|
||||
{
|
||||
@@ -39,12 +46,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
|
||||
/// <summary>
|
||||
/// Build the <see cref="DeadlineNames.DevicePrepCompletion"/> backstop deadline.
|
||||
/// Replay-safe via <see cref="EffectiveDeadlineBase"/> (floored at AgentBootUtc).
|
||||
/// Replay-safe via <see cref="EffectiveDeadlineBase"/> (floored at AgentBootUtc). Never
|
||||
/// shorter than the tenant's Hello window (<see cref="HelloSafetyWindow"/>, up to 60 min),
|
||||
/// so a tenant that allows Hello an hour gets that hour on WDP too. With a window of 30 min
|
||||
/// or more it may resolve before the tracker's timer (it counts from the desktop, the
|
||||
/// tracker from the wizard start). A tenant window that arrives after the backstop was
|
||||
/// armed (agent update mid-session) does not move it.
|
||||
/// </summary>
|
||||
internal static ActiveDeadline BuildDevicePrepCompletionDeadline(DecisionState state, DecisionSignal signal) =>
|
||||
new ActiveDeadline(
|
||||
name: DeadlineNames.DevicePrepCompletion,
|
||||
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(s_devicePrepCompletionWindow),
|
||||
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(DevicePrepCompletionWindow(state.ScenarioObservations)),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
|
||||
@@ -770,15 +770,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
&& state.HelloPolicyEnabled?.Value == false
|
||||
&& state.HelloWizardStartedUtc != null)
|
||||
{
|
||||
var wizardHelloSafetyDueAt = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var wizardHelloSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: wizardHelloSafetyDueAt,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var wizardHelloSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
@@ -886,15 +878,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
&& state.HelloPolicyEnabled == null
|
||||
&& state.HelloWizardStartedUtc == null)
|
||||
{
|
||||
var neverObservedSafetyDueAt = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var neverObservedSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: neverObservedSafetyDueAt,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var neverObservedSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
|
||||
@@ -393,6 +393,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
private DecisionStep HandleHelloSafetyDeadlineFired(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
// Stale fire of a replaced incarnation (same shape as the RealmJoin guard): the armed
|
||||
// hello_safety is due LATER than the due time this fire belongs to — e.g. the tenant
|
||||
// window arrived and moved the deadline out while the old timer's fire was already
|
||||
// queued. Deciding on it would time Hello out at the replaced, shorter window.
|
||||
foreach (var armed in state.Deadlines)
|
||||
{
|
||||
if (armed.Name == DeadlineNames.HelloSafety && armed.DueAtUtc > DeadlineDueAtUtc(signal))
|
||||
{
|
||||
var bookkept = BumpStepBookkeeping(state, signal);
|
||||
return new DecisionStep(
|
||||
bookkept,
|
||||
BuildDeadEndTransition(
|
||||
state: state,
|
||||
signal: signal,
|
||||
nextStepIndex: bookkept.StepIndex,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}",
|
||||
deadEndReason: "hello_safety_stale_superseded_by_rearm"),
|
||||
Array.Empty<DecisionEffect>());
|
||||
}
|
||||
}
|
||||
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
@@ -523,6 +544,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// the handler delegates the merge logic and only carries out the standard
|
||||
/// step bookkeeping + transition.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The one effect it can emit: a <see cref="DecisionEffectKind.ScheduleDeadline"/> that
|
||||
/// moves an already armed <see cref="DeadlineNames.HelloSafety"/> out to the tenant's
|
||||
/// Hello window when that fact arrives late (see
|
||||
/// <see cref="ExtendArmedHelloSafetyToTenantWindow"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private DecisionStep HandleEnrollmentFactsObservedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
@@ -559,6 +586,22 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
.WithCloudPc(isCloudPc, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
// Tenant Hello wait (remote config, not a registry fact) — widens the hello_safety
|
||||
// window. Set-once, and only a value that actually extends the built-in window counts
|
||||
// (the agent never stamps anything else). This signal is posted right after agent
|
||||
// start, but a backfilled ESP exit can arm hello_safety first; that deadline is moved
|
||||
// out to the tenant window.
|
||||
DecisionEffect? helloSafetyExtendEffect = null;
|
||||
if (signal.Payload != null
|
||||
&& signal.Payload.TryGetValue(SignalPayloadKeys.HelloWaitTimeoutSeconds, out var rawHelloWait)
|
||||
&& int.TryParse(rawHelloWait, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var helloWaitSeconds)
|
||||
&& helloWaitSeconds > HelloWaitTimeout.BuiltInSeconds)
|
||||
{
|
||||
builder.ScenarioObservations = builder.ScenarioObservations
|
||||
.WithHelloWaitTimeoutSeconds(helloWaitSeconds, signal.SessionSignalOrdinal);
|
||||
helloSafetyExtendEffect = ExtendArmedHelloSafetyToTenantWindow(state, builder, signal);
|
||||
}
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
@@ -567,7 +610,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.EnrollmentFactsObserved));
|
||||
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
return new DecisionStep(
|
||||
newState,
|
||||
transition,
|
||||
helloSafetyExtendEffect != null ? new[] { helloSafetyExtendEffect } : Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -959,15 +1005,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// Mirror of HandleEspExitingV1's promote branch, using this signal's instant as
|
||||
// the deadline base. EffectiveDeadlineBase still floors at AgentBootUtc to keep
|
||||
// the replay-safety guarantee.
|
||||
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
|
||||
var helloSafety = new ActiveDeadline(
|
||||
name: DeadlineNames.HelloSafety,
|
||||
dueAtUtc: dueAtUtc,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
|
||||
});
|
||||
var helloSafety = BuildHelloSafetyDeadline(state, signal);
|
||||
|
||||
builder = builder
|
||||
.WithStage(SessionStage.AwaitingHello)
|
||||
|
||||
@@ -177,6 +177,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
seen.Add("cloud_pc_marker");
|
||||
evidence["cloudPcMarker"] = OrdinalEvidence(obs.CloudPc.SourceSignalOrdinal);
|
||||
}
|
||||
// Tenant-extended Hello window — surfaced so a terminal audit trail explains a Hello
|
||||
// timeout (or AwaitingHello dwell) longer than the built-in 5 minutes.
|
||||
if (obs.HelloWaitTimeoutSeconds != null)
|
||||
{
|
||||
seen.Add("hello_wait_extended");
|
||||
evidence["helloWaitExtended"] = new Dictionary<string, object>(capacity: 2, StringComparer.Ordinal)
|
||||
{
|
||||
["ordinal"] = obs.HelloWaitTimeoutSeconds.SourceSignalOrdinal,
|
||||
["seconds"] = obs.HelloWaitTimeoutSeconds.Value,
|
||||
};
|
||||
}
|
||||
|
||||
return new SignalCensusResult(seen, timestamps, evidence);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,15 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// </summary>
|
||||
public SignalFact<bool>? CloudPc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant-config fact from <see cref="Signals.DecisionSignalKind.EnrollmentFactsObserved"/>
|
||||
/// (payload <c>helloWaitTimeoutSeconds</c>, stamped by the agent from
|
||||
/// <c>RemoteConfig.HelloWaitTimeoutSeconds</c> only when it exceeds the built-in 300 s):
|
||||
/// the <c>hello_safety</c> window the engine arms instead of the built-in one.
|
||||
/// <c>null</c> = built-in window. Set-once.
|
||||
/// </summary>
|
||||
public SignalFact<int>? HelloWaitTimeoutSeconds { get; init; }
|
||||
|
||||
public EnrollmentScenarioObservations WithShellCoreWhiteGloveSuccessSeen(long sourceSignalOrdinal) =>
|
||||
ShellCoreWhiteGloveSuccessSeen != null
|
||||
? this
|
||||
@@ -168,5 +177,10 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
CloudPc != null
|
||||
? this
|
||||
: this with { CloudPc = new SignalFact<bool>(value, sourceSignalOrdinal) };
|
||||
|
||||
public EnrollmentScenarioObservations WithHelloWaitTimeoutSeconds(int value, long sourceSignalOrdinal) =>
|
||||
HelloWaitTimeoutSeconds != null
|
||||
? this
|
||||
: this with { HelloWaitTimeoutSeconds = new SignalFact<int>(value, sourceSignalOrdinal) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +51,11 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
// --- Tenant Groups (app-internal tenant bundles for delegated admins / "MSP mode") ---
|
||||
/// <summary>Creates a group (meta row) and returns the generated groupId.</summary>
|
||||
Task<string> CreateTenantGroupAsync(string name, string createdBy);
|
||||
/// <summary>Renames a group (meta row). Returns false if the group does not exist.</summary>
|
||||
Task<bool> RenameTenantGroupAsync(string groupId, string name);
|
||||
/// <summary>
|
||||
/// Updates a group's meta row: <paramref name="name"/> and <paramref name="customerLabel"/> are each left
|
||||
/// unchanged when null; an empty label clears it. Returns false if the group does not exist.
|
||||
/// </summary>
|
||||
Task<bool> UpdateTenantGroupAsync(string groupId, string? name, string? customerLabel);
|
||||
/// <summary>Deletes a group: all rows in its partition (meta + membership) AND every UPN
|
||||
/// assignment referencing it (cross-partition RowKey scan of the assignments table).</summary>
|
||||
Task<bool> DeleteTenantGroupAsync(string groupId);
|
||||
@@ -153,6 +156,8 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public List<TenantGroupAssignment> Assignees { get; set; } = new();
|
||||
/// <summary>The managing tenant that owns this self-service group (<c>msp-{tenantId}</c>); null for operator-created groups.</summary>
|
||||
public string? OwnerTenantId { get; set; }
|
||||
/// <summary>Operator-created groups only: the name a managed customer sees for this access instead of the neutral "Platform support"; absent when none is set. The group name itself never reaches a customer.</summary>
|
||||
public string? CustomerLabel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -588,11 +588,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Body of PATCH global/tenant-groups/{groupId} — a rename.</summary>
|
||||
/// <summary>Body of PATCH global/tenant-groups/{groupId} — only the fields to change; at least one is required.</summary>
|
||||
public class UpdateTenantGroupRequest : IApiRequest
|
||||
{
|
||||
/// <summary>New display name.</summary>
|
||||
/// <summary>New display name (Global Admin UI only).</summary>
|
||||
public string? Name { get; set; }
|
||||
/// <summary>The name managed customers see for this operator group; an empty string clears it back to "Platform support". Rejected on self-service groups.</summary>
|
||||
public string? CustomerLabel { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Body of POST global/tenant-groups/{groupId}/tenants.</summary>
|
||||
|
||||
@@ -317,10 +317,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int AgentSelfMetricsIntervalSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds to wait for the Windows Hello wizard after ESP exit.
|
||||
/// Default: 30 seconds
|
||||
/// Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up
|
||||
/// to 300 keep the built-in 300 s Hello window; larger values extend it.
|
||||
/// Default: 300 seconds
|
||||
/// </summary>
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = 30;
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = HelloWaitTimeout.DefaultSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum agent lifetime in minutes. Safety net to prevent zombie agents.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Bounds of the tenant setting <see cref="TenantConfiguration.HelloWaitTimeoutSeconds"/> —
|
||||
/// how long after the Enrollment Status Page closes the agent waits for Windows Hello for
|
||||
/// Business (wizard start plus setup) before it records Hello as timed out. Single source for
|
||||
/// the backend validation, the agent's Hello tracker and the decision engine's
|
||||
/// <c>hello_safety</c> deadline; the web settings input mirrors the configurable bounds.
|
||||
/// </summary>
|
||||
public static class HelloWaitTimeout
|
||||
{
|
||||
/// <summary>Smallest configurable value.</summary>
|
||||
public const int MinSeconds = 30;
|
||||
|
||||
/// <summary>Largest configurable value (1 hour) — also the ceiling of the effective wait.</summary>
|
||||
public const int MaxSeconds = 3600;
|
||||
|
||||
/// <summary>
|
||||
/// The engine's built-in Hello window (5 minutes). Configured values up to it keep it —
|
||||
/// the old 30 s default is still stored for many tenants — only larger values extend it.
|
||||
/// </summary>
|
||||
public const int BuiltInSeconds = 300;
|
||||
|
||||
/// <summary>Default for tenants that never changed the setting: the built-in window.</summary>
|
||||
public const int DefaultSeconds = BuiltInSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// The wait a configured value stands for: clamped into
|
||||
/// [<see cref="BuiltInSeconds"/>, <see cref="MaxSeconds"/>].
|
||||
/// </summary>
|
||||
public static int EffectiveSeconds(int configuredSeconds) =>
|
||||
configuredSeconds < BuiltInSeconds ? BuiltInSeconds
|
||||
: configuredSeconds > MaxSeconds ? MaxSeconds
|
||||
: configuredSeconds;
|
||||
}
|
||||
}
|
||||
@@ -396,10 +396,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int PerformanceCollectorIntervalSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds to wait for the Windows Hello wizard after ESP exit
|
||||
/// Default: 30 seconds
|
||||
/// Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up
|
||||
/// to 300 keep the built-in 300 s Hello window; larger values extend it.
|
||||
/// Default: 300 seconds
|
||||
/// </summary>
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = 30;
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = HelloWaitTimeout.DefaultSeconds;
|
||||
|
||||
// ===== AGENT AUTH CIRCUIT BREAKER =====
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants.</summary>
|
||||
public string? OwnerTenantId { get; set; }
|
||||
public string? OwnerDomain { get; set; }
|
||||
/// <summary>The owned group's name for a self-service delegation; the neutral operator label ("Platform support") for every operator-provisioned entry — internal group names never reach a customer.</summary>
|
||||
/// <summary>The owned group's name for a self-service delegation; for an operator group its customer label, else the neutral "Platform support" (always for direct operator grants) — internal group names never reach a customer.</summary>
|
||||
public string Name { get; set; } = default!;
|
||||
/// <summary>self-service | operator</summary>
|
||||
public string Source { get; set; } = default!;
|
||||
|
||||
@@ -57,7 +57,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
|
||||
{
|
||||
question: "Is Autopilot Monitor free and open source?",
|
||||
answer:
|
||||
"Yes. The Community plan is free and stays free, includes the complete current feature set, and is meant for production fleets, not just labs. The source code is on GitHub: the agent under the MIT license, the backend, portal, and MCP server under AGPL-3.0. A commercial Pro plan is coming for organizations that need reliability commitments, priority support, longer data retention, and delegated administration across customer tenants.",
|
||||
"Yes. The Community plan is free, stays free, and is meant for production fleets, not just labs: live monitoring, the full rules engine including custom rules, fleet analytics, notifications, diagnostics, and AI integration through MCP within usage limits, with 90-day data retention. The source code is on GitHub: the agent under the MIT license, the backend, portal, and MCP server under AGPL-3.0. The optional Pro plan, sold through Microsoft Marketplace and Cleverbridge, adds 365-day retention, higher API and AI usage limits, delegated administration across customer tenants, OOBE bootstrap sessions, reliability commitments, and priority support; a tenant administrator can try it free for 30 days.",
|
||||
},
|
||||
{
|
||||
question: "How is the agent deployed, and does it stay on the device?",
|
||||
|
||||
@@ -23,6 +23,9 @@ interface SlotPromptState extends SlotLimitError {
|
||||
retry: { key: string; url: string; method: string; body: string | undefined; ok: string };
|
||||
}
|
||||
|
||||
/** What a managed customer sees for an operator group without a customer label (DelegationSelfService.OperatorLabel). */
|
||||
const OPERATOR_LABEL = "Platform support";
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
DelegatedReader: "Reader (read-only)",
|
||||
DelegatedAdmin: "Admin (read + write)",
|
||||
@@ -160,6 +163,23 @@ export function SectionTenantGroups() {
|
||||
[mutate],
|
||||
);
|
||||
|
||||
const handleCustomerLabel = useCallback(
|
||||
async (t: TenantGroup) => {
|
||||
const input = prompt(`Name managed customers see for this group (empty = "${OPERATOR_LABEL}"):`, t.customerLabel ?? "");
|
||||
if (input === null) return;
|
||||
const customerLabel = input.trim();
|
||||
if (customerLabel === (t.customerLabel ?? "")) return;
|
||||
await mutate(
|
||||
`label:${t.groupId}`,
|
||||
api.tenantGroups.update(t.groupId),
|
||||
"PATCH",
|
||||
jsonBody<UpdateTenantGroupRequest>({ customerLabel }),
|
||||
customerLabel ? `Customers now see "${customerLabel}".` : `Customers now see "${OPERATOR_LABEL}".`,
|
||||
);
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
const handleDeleteGroup = useCallback(
|
||||
async (t: TenantGroup) => {
|
||||
const extra = t.assigneeCount > 0 ? ` ${t.assigneeCount} assignee(s) will lose this access.` : "";
|
||||
@@ -363,6 +383,11 @@ export function SectionTenantGroups() {
|
||||
Owned by {domainOf(t.ownerTenantId) || t.ownerTenantId}
|
||||
</span>
|
||||
)}
|
||||
{!t.ownerTenantId && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Customers see:{" "}<span className="text-gray-700">{t.customerLabel || OPERATOR_LABEL}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!t.ownerTenantId && (
|
||||
@@ -373,6 +398,13 @@ export function SectionTenantGroups() {
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCustomerLabel(t)}
|
||||
disabled={busyKey === `label:${t.groupId}`}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Customer label
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteGroup(t)}
|
||||
className="px-3 py-1 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
|
||||
@@ -516,7 +548,8 @@ export function SectionTenantGroups() {
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Reader is read-only (secrets redacted). Only onboarded tenants appear in the dropdown. Changes take
|
||||
effect on the assignee’s next request.
|
||||
effect on the assignee’s next request. Customers never see a group’s name: they see its customer
|
||||
label, or “{OPERATOR_LABEL}” when none is set.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import AppInsightsInit from "../components/AppInsightsInit";
|
||||
import ChunkReloadRecovery from "../components/ChunkReloadRecovery";
|
||||
import { HostRoutingGuard } from "../components/HostRoutingGuard";
|
||||
import { LegacyPathRedirect } from "../components/LegacyPathRedirect";
|
||||
import { API_BASE_URL, DOCS_URL, SITE_URL } from "@/utils/config";
|
||||
import { API_BASE_URL, DOCS_URL, SITE_URL, sitePageUrl } from "@/utils/config";
|
||||
import { AUTH_HINT_INLINE_SCRIPT } from "@/lib/authHint";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
@@ -145,7 +145,7 @@ const jsonLd = {
|
||||
name: "Community plan",
|
||||
price: "0",
|
||||
priceCurrency: "EUR",
|
||||
url: `${SITE_URL}/plans`,
|
||||
url: sitePageUrl("/plans"),
|
||||
},
|
||||
author: { "@id": PERSON_ID },
|
||||
creator: { "@id": PERSON_ID },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthF
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { classifyAccessCheck, type AccessCheckOutcome, type AccessCheckPayload } from "@/lib/accessCheck";
|
||||
import { primaryClientId } from "@/lib/authApp";
|
||||
import { canManageBootstrapSessions } from "@/lib/tenantScope";
|
||||
|
||||
type ValidationTrigger = "autopilot" | "corporate" | "device-preparation";
|
||||
|
||||
@@ -27,6 +28,7 @@ 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 { SECTION_FIELD_MAP, type SectionFieldSpec, type SettingsSectionName } from "./sectionFieldMap";
|
||||
import { HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS } from "./lib/helloWaitTimeout";
|
||||
import { changedTenantConfigFields, patchTenantConfigFields } from "@/lib/tenantConfigSave";
|
||||
import { looksLikeGuid, type MemberKind } from "@/utils/principalKeys";
|
||||
import { type BootstrapSessionItem } from "./components/BootstrapSessionsSection";
|
||||
@@ -428,7 +430,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// Collector settings
|
||||
const [enablePerformanceCollector, setEnablePerformanceCollector] = useState(true);
|
||||
const [performanceCollectorInterval, setPerformanceCollectorInterval] = useState(30);
|
||||
const [helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds] = useState(30);
|
||||
const [helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds] = useState(HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
|
||||
const [autopilotConsentInProgress, setAutopilotConsentInProgress] = useState(false);
|
||||
|
||||
// Agent behavior
|
||||
@@ -591,7 +593,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setSessionTimeoutHours(data.sessionTimeoutHours ?? 5);
|
||||
setEnablePerformanceCollector(data.enablePerformanceCollector ?? true);
|
||||
setPerformanceCollectorInterval(data.performanceCollectorIntervalSeconds ?? 30);
|
||||
setHelloWaitTimeoutSeconds(data.helloWaitTimeoutSeconds ?? 30);
|
||||
setHelloWaitTimeoutSeconds(data.helloWaitTimeoutSeconds ?? HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
|
||||
setSelfDestructOnComplete(data.selfDestructOnComplete ?? true);
|
||||
setKeepLogFile(data.keepLogFile ?? false);
|
||||
setRebootOnComplete(data.rebootOnComplete ?? false);
|
||||
@@ -721,16 +723,19 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
}
|
||||
}, [tenantId, getAccessToken]);
|
||||
|
||||
// The list route is the BootstrapManagerOrGA tier: any other role only ever received a 403.
|
||||
const canManageBootstrap = canManageBootstrapSessions(user);
|
||||
|
||||
useEffect(() => {
|
||||
// Effective availability (mirrors backend IsBootstrapEnabled): Pro plan includes bootstrap;
|
||||
// the per-tenant GA flag is the additive Community enable.
|
||||
const bootstrapAvailable = editionInfo.edition === "pro" || config?.bootstrapTokenEnabled;
|
||||
if (!tenantId || !bootstrapAvailable) return;
|
||||
if (!tenantId || !bootstrapAvailable || !canManageBootstrap) return;
|
||||
const run = async () => {
|
||||
await fetchBootstrapSessions();
|
||||
};
|
||||
void run();
|
||||
}, [tenantId, editionInfo.edition, config?.bootstrapTokenEnabled, fetchBootstrapSessions]);
|
||||
}, [tenantId, editionInfo.edition, config?.bootstrapTokenEnabled, canManageBootstrap, fetchBootstrapSessions]);
|
||||
|
||||
// Built-in sections + global diagnostics paths are read-only context for every role and
|
||||
// live in useDiagnosticsPathsCatalog (GET /api/diagnostics/paths), not in this provider.
|
||||
@@ -1223,7 +1228,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
if (!config) return;
|
||||
setEnablePerformanceCollector(config.enablePerformanceCollector ?? true);
|
||||
setPerformanceCollectorInterval(config.performanceCollectorIntervalSeconds ?? 30);
|
||||
setHelloWaitTimeoutSeconds(config.helloWaitTimeoutSeconds ?? 30);
|
||||
setHelloWaitTimeoutSeconds(config.helloWaitTimeoutSeconds ?? HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
|
||||
setSelfDestructOnComplete(config.selfDestructOnComplete ?? true);
|
||||
setKeepLogFile(config.keepLogFile ?? false);
|
||||
setRebootOnComplete(config.rebootOnComplete ?? false);
|
||||
|
||||
@@ -4,6 +4,12 @@ import SaveResetBar from "./SaveResetBar";
|
||||
import ReadOnlyFieldset from "./ReadOnlyFieldset";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import {
|
||||
clampHelloWaitTimeoutSeconds,
|
||||
HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS,
|
||||
HELLO_WAIT_TIMEOUT_MAX_SECONDS,
|
||||
HELLO_WAIT_TIMEOUT_MIN_SECONDS,
|
||||
} from "../lib/helloWaitTimeout";
|
||||
|
||||
interface AgentSettingsSectionProps {
|
||||
enablePerformanceCollector: boolean;
|
||||
@@ -429,16 +435,16 @@ export default function AgentSettingsSection({
|
||||
<span className="text-sm text-gray-600">Timeout:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={30}
|
||||
max={300}
|
||||
min={HELLO_WAIT_TIMEOUT_MIN_SECONDS}
|
||||
max={HELLO_WAIT_TIMEOUT_MAX_SECONDS}
|
||||
value={helloWaitTimeoutSeconds}
|
||||
onChange={(e) => setHelloWaitTimeoutSeconds(parseInt(e.target.value) || 30)}
|
||||
onBlur={() => setHelloWaitTimeoutSeconds(Math.max(30, Math.min(300, helloWaitTimeoutSeconds)))}
|
||||
onChange={(e) => setHelloWaitTimeoutSeconds(parseInt(e.target.value) || HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS)}
|
||||
onBlur={() => setHelloWaitTimeoutSeconds(clampHelloWaitTimeoutSeconds(helloWaitTimeoutSeconds))}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">seconds</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 300 seconds (5 minutes)</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 3600 seconds (1 hour). Values up to 300 seconds use the built-in 5-minute wait.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
|
||||
import {
|
||||
clampHelloWaitTimeoutSeconds,
|
||||
HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS,
|
||||
HELLO_WAIT_TIMEOUT_MAX_SECONDS,
|
||||
HELLO_WAIT_TIMEOUT_MIN_SECONDS,
|
||||
} from "../helloWaitTimeout";
|
||||
|
||||
describe("Hello wait timeout bounds", () => {
|
||||
it("mirror the server's HelloWaitTimeout range", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../../../../../Shared/AutopilotMonitor.Shared/Models/Config/HelloWaitTimeout.cs"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toMatch(new RegExp(`MinSeconds = ${HELLO_WAIT_TIMEOUT_MIN_SECONDS};`));
|
||||
expect(source).toMatch(new RegExp(`MaxSeconds = ${HELLO_WAIT_TIMEOUT_MAX_SECONDS};`));
|
||||
});
|
||||
|
||||
it("default matches the C# model default carried by the shared manifest", () => {
|
||||
expect(SHARED_MANIFEST.tenantConfiguration.defaults.helloWaitTimeoutSeconds).toBe(HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
|
||||
});
|
||||
|
||||
it("keeps values inside the range", () => {
|
||||
expect(clampHelloWaitTimeoutSeconds(30)).toBe(30);
|
||||
expect(clampHelloWaitTimeoutSeconds(300)).toBe(300);
|
||||
expect(clampHelloWaitTimeoutSeconds(3600)).toBe(3600);
|
||||
});
|
||||
|
||||
it("clamps values outside the range", () => {
|
||||
expect(clampHelloWaitTimeoutSeconds(0)).toBe(30);
|
||||
expect(clampHelloWaitTimeoutSeconds(29)).toBe(30);
|
||||
expect(clampHelloWaitTimeoutSeconds(86400)).toBe(3600);
|
||||
});
|
||||
|
||||
it("resolves a non-numeric value to the default and rounds fractions", () => {
|
||||
expect(clampHelloWaitTimeoutSeconds(Number.NaN)).toBe(300);
|
||||
expect(clampHelloWaitTimeoutSeconds(1800.6)).toBe(1801);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Bounds of the tenant Hello wait (`helloWaitTimeoutSeconds`). Mirrors `HelloWaitTimeout` in
|
||||
* AutopilotMonitor.Shared — the server rejects a changed value outside min/max (the test next to
|
||||
* this file pins all three). Values up to 300 s keep the agent's built-in 5-minute Hello window;
|
||||
* larger values extend it.
|
||||
*/
|
||||
export const HELLO_WAIT_TIMEOUT_MIN_SECONDS = 30;
|
||||
export const HELLO_WAIT_TIMEOUT_MAX_SECONDS = 3600;
|
||||
export const HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS = 300;
|
||||
|
||||
/** Clamps into the configurable range; a non-numeric value resolves to the default. */
|
||||
export function clampHelloWaitTimeoutSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) return HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS;
|
||||
return Math.min(HELLO_WAIT_TIMEOUT_MAX_SECONDS, Math.max(HELLO_WAIT_TIMEOUT_MIN_SECONDS, Math.round(value)));
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { useTenantConfig } from "../../TenantConfigContext";
|
||||
import { isProViaMsp, missingContactProfileParts, trialDaysLeft } from "@/lib/edition";
|
||||
import { PlanCards } from "@/components/plans/PlanCards";
|
||||
import { SITE_URL } from "@/utils/config";
|
||||
import { sitePageUrl } from "@/utils/config";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
|
||||
@@ -108,7 +108,7 @@ export function SectionPlan() {
|
||||
<p className="mt-3 text-xs text-gray-600">
|
||||
Ready to buy? Pro is sold through Microsoft Marketplace and Cleverbridge —{" "}
|
||||
<a
|
||||
href={`${SITE_URL}/buy`}
|
||||
href={sitePageUrl("/buy")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-700 hover:underline dark:text-purple-300"
|
||||
|
||||
@@ -3,9 +3,7 @@ export const dynamic = "force-static";
|
||||
|
||||
import type { MetadataRoute } from "next";
|
||||
import { PAGE_LASTMOD } from "@/utils/page-lastmod.generated";
|
||||
import { SITE_URL } from "@/utils/config";
|
||||
|
||||
const BASE_URL = SITE_URL;
|
||||
import { sitePageUrl } from "@/utils/config";
|
||||
|
||||
// Build-time only (static export): a URL missing from PAGE_LASTMOD means
|
||||
// scripts/generate-lastmod.js drifted from this list -- fail the build rather
|
||||
@@ -24,49 +22,49 @@ function lastmod(urlPath: string): Date {
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: `${BASE_URL}/`,
|
||||
url: sitePageUrl("/"),
|
||||
lastModified: lastmod("/"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/about`,
|
||||
url: sitePageUrl("/about"),
|
||||
lastModified: lastmod("/about"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/get-started`,
|
||||
url: sitePageUrl("/get-started"),
|
||||
lastModified: lastmod("/get-started"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/plans`,
|
||||
url: sitePageUrl("/plans"),
|
||||
lastModified: lastmod("/plans"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/buy`,
|
||||
url: sitePageUrl("/buy"),
|
||||
lastModified: lastmod("/buy"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/help`,
|
||||
url: sitePageUrl("/help"),
|
||||
lastModified: lastmod("/help"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/privacy`,
|
||||
url: sitePageUrl("/privacy"),
|
||||
lastModified: lastmod("/privacy"),
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/terms`,
|
||||
url: sitePageUrl("/terms"),
|
||||
lastModified: lastmod("/terms"),
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.3,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
|
||||
import { trackEvent } from '@/lib/appInsights';
|
||||
import type { SignalRMessageName } from '@/lib/signalrMessages';
|
||||
import { createGroupRegistry } from '@/lib/signalrGroupRegistry';
|
||||
import { refusedJoinStatus } from '@/lib/signalrGroupAccess';
|
||||
import { useLatest } from '@/hooks/useLatest';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { ApiError, fetchOk, jsonBody, type GetAccessToken } from "@/lib/apiClient";
|
||||
@@ -67,7 +68,15 @@ function getTransportName(conn: HubConnection): string {
|
||||
}
|
||||
|
||||
export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
const { getAccessToken, isAuthenticated } = useAuth();
|
||||
const { getAccessToken, isAuthenticated, user } = useAuth();
|
||||
// The caller's standing for the join gate (lib/signalrGroupAccess), held as primitives so joinGroup
|
||||
// keeps its identity until the standing itself changes — in practice once, when auth/me resolves,
|
||||
// in the same commit that delivers the tenant id the consumers join with.
|
||||
const callerTenantId = user?.tenantId ?? '';
|
||||
const callerRole = user?.role ?? null;
|
||||
const callerIsTenantAdmin = user?.isTenantAdmin ?? false;
|
||||
const callerIsGlobalAdmin = user?.isGlobalAdmin ?? false;
|
||||
const callerIsGlobalReader = user?.isGlobalReader ?? false;
|
||||
const [connection, setConnection] = useState<HubConnection | null>(null);
|
||||
const [connectionState, setConnectionState] = useState<HubConnectionState>(HubConnectionState.Disconnected);
|
||||
const connectionRef = useRef<HubConnection | null>(null);
|
||||
@@ -353,6 +362,24 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A join the backend refuses by rule for this caller is not sent. The consumer learns it exactly
|
||||
// as from the server's answer, and nothing is held for the reconnect rejoin.
|
||||
const caller = callerTenantId
|
||||
? {
|
||||
tenantId: callerTenantId,
|
||||
role: callerRole,
|
||||
isTenantAdmin: callerIsTenantAdmin,
|
||||
isGlobalAdmin: callerIsGlobalAdmin,
|
||||
isGlobalReader: callerIsGlobalReader,
|
||||
}
|
||||
: null;
|
||||
const refused = refusedJoinStatus(groupName, caller, options?.serialNumber);
|
||||
if (refused !== null) {
|
||||
console.info(`[SignalR] Not joining group ${groupName}: the server refuses it for this user (status ${refused})`);
|
||||
options?.onDenied?.(refused);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to Set immediately to prevent race conditions with multiple simultaneous calls
|
||||
joinedGroupsRef.current.add(groupName);
|
||||
if (options?.serialNumber) {
|
||||
@@ -395,7 +422,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
joinSerialsRef.current.delete(groupName);
|
||||
syncJoinedGroups();
|
||||
}
|
||||
}, [groupRegistry, getToken, syncJoinedGroups]);
|
||||
}, [groupRegistry, getToken, syncJoinedGroups, callerTenantId, callerRole, callerIsTenantAdmin, callerIsGlobalAdmin, callerIsGlobalReader]);
|
||||
|
||||
const leaveGroup = useCallback((groupName: string): Promise<void> => {
|
||||
// Only this consumer's reference is dropped here; the hub leave follows from the registry
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupTenantId, refusedJoinStatus, type GroupJoinCaller } from "../signalrGroupAccess";
|
||||
|
||||
/**
|
||||
* The client gate must refuse exactly what the backend refuses by rule (SignalRGroupHelper +
|
||||
* SignalRAddToGroupFunction) for the caller's HOME tenant, and leave every cross-tenant join
|
||||
* to the backend's admission.
|
||||
*/
|
||||
|
||||
const HOME = "11111111-1111-1111-1111-111111111111";
|
||||
const OTHER = "22222222-2222-2222-2222-222222222222";
|
||||
const SESSION = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
const broadcast = (tid: string) => `tenant-${tid}`;
|
||||
const notifyMember = (tid: string) => `tenant-${tid}-notify-member`;
|
||||
const notifyAdmin = (tid: string) => `tenant-${tid}-notify-admin`;
|
||||
const session = (tid: string) => `session-${tid}-${SESSION}`;
|
||||
|
||||
function caller(overrides: Partial<GroupJoinCaller> = {}): GroupJoinCaller {
|
||||
return {
|
||||
tenantId: HOME,
|
||||
isTenantAdmin: false,
|
||||
isGlobalAdmin: false,
|
||||
isGlobalReader: false,
|
||||
isDelegated: false,
|
||||
role: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupTenantId", () => {
|
||||
it("parses every tenant-scoped group format", () => {
|
||||
expect(groupTenantId(broadcast(HOME))).toBe(HOME);
|
||||
expect(groupTenantId(notifyMember(HOME))).toBe(HOME);
|
||||
expect(groupTenantId(notifyAdmin(HOME))).toBe(HOME);
|
||||
expect(groupTenantId(session(HOME))).toBe(HOME);
|
||||
});
|
||||
|
||||
it("is null or empty for names the backend cannot attribute", () => {
|
||||
expect(groupTenantId("tenant-")).toBe("");
|
||||
expect(groupTenantId("session-abc")).toBeNull();
|
||||
expect(groupTenantId("global-admins")).toBeNull();
|
||||
expect(groupTenantId("something-else")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusedJoinStatus", () => {
|
||||
it("answers 400 for a name the backend cannot parse, whoever asks", () => {
|
||||
// The dashboard used to join "tenant-" before auth/me delivered the tenant id.
|
||||
for (const c of [null, caller(), caller({ isGlobalAdmin: true })]) {
|
||||
expect(refusedJoinStatus("tenant-", c)).toBe(400);
|
||||
expect(refusedJoinStatus("session-abc", c)).toBe(400);
|
||||
expect(refusedJoinStatus("unknown-group", c)).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves well-formed joins to the backend while the caller is unknown", () => {
|
||||
for (const g of [broadcast(HOME), notifyMember(HOME), notifyAdmin(HOME), session(HOME), "global-admins"]) {
|
||||
expect(refusedJoinStatus(g, null)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a roleless caller its home tenant's member groups and serial-less session groups", () => {
|
||||
const roleless = caller();
|
||||
expect(refusedJoinStatus(broadcast(HOME), roleless)).toBe(403);
|
||||
expect(refusedJoinStatus(notifyMember(HOME), roleless)).toBe(403);
|
||||
expect(refusedJoinStatus(notifyAdmin(HOME), roleless)).toBe(403);
|
||||
expect(refusedJoinStatus(session(HOME), roleless)).toBe(403);
|
||||
});
|
||||
|
||||
it("lets a roleless caller join a session group with a serial number (Progress Portal)", () => {
|
||||
expect(refusedJoinStatus(session(HOME), caller(), "SERIAL-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a delegated admin without own role its HOME tenant's broadcast group", () => {
|
||||
// hasTenantReadScope would admit them; the backend does not.
|
||||
expect(refusedJoinStatus(broadcast(HOME), caller({ isDelegated: true }))).toBe(403);
|
||||
});
|
||||
|
||||
it("never decides a cross-tenant join (platform or delegated admission is the backend's)", () => {
|
||||
for (const c of [caller(), caller({ isDelegated: true }), caller({ role: "Viewer" })]) {
|
||||
expect(refusedJoinStatus(broadcast(OTHER), c)).toBeNull();
|
||||
expect(refusedJoinStatus(notifyMember(OTHER), c)).toBeNull();
|
||||
expect(refusedJoinStatus(notifyAdmin(OTHER), c)).toBeNull();
|
||||
expect(refusedJoinStatus(session(OTHER), c)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["Operator", "Viewer"] as const)("admits a %s to every home group except the admin notify group", (role) => {
|
||||
const member = caller({ role });
|
||||
expect(refusedJoinStatus(broadcast(HOME), member)).toBeNull();
|
||||
expect(refusedJoinStatus(notifyMember(HOME), member)).toBeNull();
|
||||
expect(refusedJoinStatus(session(HOME), member)).toBeNull();
|
||||
expect(refusedJoinStatus(notifyAdmin(HOME), member)).toBe(403);
|
||||
});
|
||||
|
||||
it("admits the tenant Admin and a Global Admin to every home group", () => {
|
||||
for (const c of [caller({ isTenantAdmin: true, role: "Admin" }), caller({ isGlobalAdmin: true })]) {
|
||||
for (const g of [broadcast(HOME), notifyMember(HOME), notifyAdmin(HOME), session(HOME)]) {
|
||||
expect(refusedJoinStatus(g, c)).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the admin notify group closed to a Global Reader without the Admin role", () => {
|
||||
const reader = caller({ isGlobalReader: true });
|
||||
expect(refusedJoinStatus(broadcast(HOME), reader)).toBeNull();
|
||||
expect(refusedJoinStatus(notifyMember(HOME), reader)).toBeNull();
|
||||
expect(refusedJoinStatus(notifyAdmin(HOME), reader)).toBe(403);
|
||||
});
|
||||
|
||||
it("matches the home tenant case-insensitively", () => {
|
||||
expect(refusedJoinStatus(broadcast(HOME.toUpperCase()), caller())).toBe(403);
|
||||
});
|
||||
|
||||
it("opens global-admins to platform scope only", () => {
|
||||
expect(refusedJoinStatus("global-admins", caller())).toBe(403);
|
||||
expect(refusedJoinStatus("global-admins", caller({ isTenantAdmin: true, role: "Admin" }))).toBe(403);
|
||||
expect(refusedJoinStatus("global-admins", caller({ isGlobalReader: true }))).toBeNull();
|
||||
expect(refusedJoinStatus("global-admins", caller({ isGlobalAdmin: true }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canManageBootstrapSessions,
|
||||
hasOwnTenantOrPlatformRole,
|
||||
hasTenantReadScope,
|
||||
type TenantScopeUser,
|
||||
@@ -81,3 +82,28 @@ describe("hasTenantReadScope", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canManageBootstrapSessions (mirror of BootstrapManagerOrGA)", () => {
|
||||
it("is false for null/undefined and a roleless user", () => {
|
||||
expect(canManageBootstrapSessions(null)).toBe(false);
|
||||
expect(canManageBootstrapSessions(undefined)).toBe(false);
|
||||
expect(canManageBootstrapSessions(user())).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for a Global Admin and the tenant Admin", () => {
|
||||
expect(canManageBootstrapSessions(user({ isGlobalAdmin: true }))).toBe(true);
|
||||
expect(canManageBootstrapSessions(user({ isTenantAdmin: true }))).toBe(true);
|
||||
expect(canManageBootstrapSessions(user({ role: "Admin" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("admits an Operator only with the bootstrap grant", () => {
|
||||
expect(canManageBootstrapSessions(user({ role: "Operator" }))).toBe(false);
|
||||
expect(canManageBootstrapSessions(user({ role: "Operator", canManageBootstrapTokens: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a Viewer even with the flag, a Global Reader and a delegated admin (write tier)", () => {
|
||||
expect(canManageBootstrapSessions(user({ role: "Viewer", canManageBootstrapTokens: true }))).toBe(false);
|
||||
expect(canManageBootstrapSessions(user({ isGlobalReader: true }))).toBe(false);
|
||||
expect(canManageBootstrapSessions(user({ isDelegated: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { hasOwnTenantOrPlatformRole, type TenantScopeUser } from "./tenantScope";
|
||||
|
||||
/**
|
||||
* Client mirror of the SignalR join rules the backend decides from the caller's HOME-tenant
|
||||
* standing alone (SignalRGroupHelper + SignalRAddToGroupFunction). A join the backend refuses by
|
||||
* rule is not sent: it only ever produced a 4xx row and an api_request_failed event per page visit.
|
||||
*
|
||||
* Cross-tenant joins are deliberately NOT decided here, exactly as the backend helpers leave them
|
||||
* to the cross-tenant admission (platform scope, or a delegated grant over the group's tenant).
|
||||
* "Member" is hasOwnTenantOrPlatformRole, not hasTenantReadScope: a delegated admin without an
|
||||
* own-tenant role reads the managed tenants but is refused its home tenant's member groups.
|
||||
*/
|
||||
|
||||
/** The caller's standing as auth/me reports it; `tenantId` is the HOME tenant. */
|
||||
export interface GroupJoinCaller extends TenantScopeUser {
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
const NOTIFY_MEMBER_SUFFIX = "-notify-member";
|
||||
const NOTIFY_ADMIN_SUFFIX = "-notify-admin";
|
||||
const GLOBAL_ADMINS_GROUP = "global-admins";
|
||||
|
||||
/** The tenant a group belongs to, parsed like SignalRGroupHelper.ExtractTenantIdFromGroupName. */
|
||||
export function groupTenantId(groupName: string): string | null {
|
||||
if (groupName.startsWith("session-")) {
|
||||
// "session-{tenantId}-{sessionId}": the tenant GUID is dash-parts 1..5.
|
||||
const parts = groupName.split("-");
|
||||
return parts.length >= 7 ? parts.slice(1, 6).join("-") : null;
|
||||
}
|
||||
if (groupName.startsWith("tenant-")) {
|
||||
const rest = groupName.slice("tenant-".length);
|
||||
if (rest.endsWith(NOTIFY_ADMIN_SUFFIX)) return rest.slice(0, -NOTIFY_ADMIN_SUFFIX.length);
|
||||
if (rest.endsWith(NOTIFY_MEMBER_SUFFIX)) return rest.slice(0, -NOTIFY_MEMBER_SUFFIX.length);
|
||||
return rest;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The status the backend answers this join with by rule — 400 for a group name it cannot parse,
|
||||
* 403 for a refusal — or null when the join may be admitted. Without a caller (auth/me not
|
||||
* resolved yet) only the name check applies; the backend decides the rest.
|
||||
*/
|
||||
export function refusedJoinStatus(
|
||||
groupName: string,
|
||||
caller: GroupJoinCaller | null,
|
||||
serialNumber?: string,
|
||||
): 400 | 403 | null {
|
||||
if (groupName === GLOBAL_ADMINS_GROUP) {
|
||||
return caller && !caller.isGlobalAdmin && !caller.isGlobalReader ? 403 : null;
|
||||
}
|
||||
|
||||
const tenantId = groupTenantId(groupName);
|
||||
if (!tenantId) return 400;
|
||||
if (!caller?.tenantId || tenantId.toLowerCase() !== caller.tenantId.toLowerCase()) return null;
|
||||
|
||||
if (groupName.endsWith(NOTIFY_ADMIN_SUFFIX)) {
|
||||
return caller.isTenantAdmin || caller.isGlobalAdmin ? null : 403;
|
||||
}
|
||||
if (hasOwnTenantOrPlatformRole(caller)) return null;
|
||||
|
||||
// No own-tenant role and no platform scope: the broadcast and member notification groups are
|
||||
// refused; a session group needs the device's serial number as knowledge proof.
|
||||
if (groupName.startsWith("session-")) return serialNumber ? null : 403;
|
||||
return 403;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface TenantScopeUser {
|
||||
isGlobalReader?: boolean;
|
||||
isDelegated?: boolean;
|
||||
role?: string | null;
|
||||
canManageBootstrapTokens?: boolean;
|
||||
}
|
||||
|
||||
/** The three assignable tenant roles (mirrors Constants.TenantRoles in the backend). */
|
||||
@@ -50,3 +51,21 @@ export function hasTenantReadScope(
|
||||
if (!user) return false;
|
||||
return hasOwnTenantOrPlatformRole(user) || !!user.isDelegated;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user may list and manage their OWN tenant's bootstrap sessions — the client
|
||||
* mirror of the backend's BootstrapManagerOrGA tier: Global Admin, the tenant Admin, or an
|
||||
* Operator granted "manage bootstrap tokens". It is a write tier, so neither the read-only
|
||||
* Global Reader nor a delegated grant is admitted.
|
||||
*/
|
||||
export function canManageBootstrapSessions(
|
||||
user: TenantScopeUser | null | undefined
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
return !!(
|
||||
user.isGlobalAdmin ||
|
||||
user.isTenantAdmin ||
|
||||
user.role === "Admin" ||
|
||||
(user.role === "Operator" && user.canManageBootstrapTokens)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
> Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments managed through Microsoft Intune. A lightweight, temporary agent streams enrollment events to a web portal, analyze rules flag failure patterns automatically, and a Model Context Protocol (MCP) server lets AI assistants query the data. Created and maintained by Oliver Kieselbach (Microsoft MVP); the hosted service is operated by glueckkanja AG and runs in Azure Germany West Central.
|
||||
|
||||
Autopilot Monitor is for IT administrators, helpdesk engineers, and managed service providers who deploy Windows devices with Intune and Autopilot. The Community plan is free and covers the full current feature set; a Pro plan with reliability commitments, priority support, longer retention, and delegated administration across customer tenants is coming.
|
||||
Autopilot Monitor is for IT administrators, helpdesk engineers, and managed service providers who deploy Windows devices with Intune and Autopilot. The Community plan is free and stays free: live monitoring, the full rules engine, fleet analytics, and AI integration (MCP) within usage limits. The optional Pro plan adds 365-day instead of 90-day retention, higher usage limits, delegated administration across customer tenants, OOBE bootstrap sessions, reliability commitments, and priority support; it is sold through Microsoft Marketplace and Cleverbridge, and a tenant administrator can try it free for 30 days.
|
||||
|
||||
## Portal
|
||||
|
||||
- [Home](https://www.autopilotmonitor.com/): product overview
|
||||
- [About](https://www.autopilotmonitor.com/about): what it is, how it works, common questions
|
||||
- [Get started](https://www.autopilotmonitor.com/get-started): first steps for a new tenant
|
||||
- [Plans](https://www.autopilotmonitor.com/plans): Community vs. Pro
|
||||
- [Help](https://www.autopilotmonitor.com/help): support channels
|
||||
- [Privacy Policy](https://www.autopilotmonitor.com/privacy)
|
||||
- [Terms of Use](https://www.autopilotmonitor.com/terms)
|
||||
- [About](https://www.autopilotmonitor.com/about/): what it is, how it works, common questions
|
||||
- [Get started](https://www.autopilotmonitor.com/get-started/): first steps for a new tenant
|
||||
- [Plans](https://www.autopilotmonitor.com/plans/): Community vs. Pro
|
||||
- [Get Pro](https://www.autopilotmonitor.com/buy/): purchase options and the free 30-day trial
|
||||
- [Help](https://www.autopilotmonitor.com/help/): support channels
|
||||
- [Privacy Policy](https://www.autopilotmonitor.com/privacy/)
|
||||
- [Terms of Use](https://www.autopilotmonitor.com/terms/)
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import nextConfig from "../../next.config";
|
||||
import sitemap from "../../app/sitemap";
|
||||
import { SITE_URL, sitePageUrl } from "../config";
|
||||
|
||||
/**
|
||||
* Public pages are served at /page/ only: the static export writes
|
||||
* page/index.html (next.config trailingSlash: true), SWA's trailingSlash "auto"
|
||||
* 301-redirects /page to /page/, and Next emits /page/ as the canonical URL.
|
||||
* Page URLs handed to crawlers outside Next metadata (sitemap, llms.txt) must
|
||||
* use that form, or each one is a redirect to a different canonical URL
|
||||
* (Search Console: "Page with redirect").
|
||||
*/
|
||||
|
||||
const WEB_ROOT = join(__dirname, "..", "..");
|
||||
|
||||
/** Served form: SITE_URL origin, path "/" or "/segment/.../". */
|
||||
function expectServedForm(url: string): void {
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin, url).toBe(SITE_URL);
|
||||
expect(parsed.pathname, url).toMatch(/^\/(?:[^/]+\/)*$/);
|
||||
}
|
||||
|
||||
describe("public page URLs use the served /page/ form", () => {
|
||||
it("the export and SWA serve public pages at /page/", () => {
|
||||
expect(nextConfig.trailingSlash).toBe(true);
|
||||
const swa = JSON.parse(readFileSync(join(WEB_ROOT, "staticwebapp.config.json"), "utf-8"));
|
||||
expect(swa.trailingSlash).toBe("auto");
|
||||
});
|
||||
|
||||
it("sitePageUrl appends the trailing slash exactly once", () => {
|
||||
expect(sitePageUrl("/")).toBe(`${SITE_URL}/`);
|
||||
expect(sitePageUrl("/plans")).toBe(`${SITE_URL}/plans/`);
|
||||
expect(sitePageUrl("/plans/")).toBe(`${SITE_URL}/plans/`);
|
||||
});
|
||||
|
||||
it("sitemap lists every page in the served form", () => {
|
||||
const urls = sitemap().map((entry) => entry.url);
|
||||
expect(urls.length).toBeGreaterThan(0);
|
||||
for (const url of urls) expectServedForm(url);
|
||||
});
|
||||
|
||||
it("llms.txt links site pages in the served form", () => {
|
||||
const llms = readFileSync(join(WEB_ROOT, "public", "llms.txt"), "utf-8");
|
||||
const links = [...llms.matchAll(/\]\(([^)\s]+)\)/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((url) => new URL(url).origin === SITE_URL);
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
for (const url of links) expectServedForm(url);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,19 @@ export const DOCS_URL = "https://docs.autopilotmonitor.com";
|
||||
/** Public marketing/product website (also feeds metadataBase/sitemap/robots). */
|
||||
export const SITE_URL = "https://www.autopilotmonitor.com";
|
||||
|
||||
/**
|
||||
* Absolute URL of a public page in the /page/ form the site serves: the static
|
||||
* export writes page/index.html (next.config trailingSlash: true) and SWA
|
||||
* 301-redirects /page to /page/. Next applies that form to metadata URLs
|
||||
* (canonical, og:url) itself; every page URL built outside metadata (sitemap,
|
||||
* JSON-LD, absolute links) goes through here, so crawlers get the canonical URL
|
||||
* instead of a redirect. sitePageUrl.guard.test.ts pins the coupling.
|
||||
*/
|
||||
export function sitePageUrl(path: `/${string}`): string {
|
||||
const slashed = path.endsWith("/") ? path : `${path}/`;
|
||||
return `${SITE_URL}${slashed}`;
|
||||
}
|
||||
|
||||
/** Customer portal (deep links rendered into generated bootstrap scripts). */
|
||||
export const PORTAL_URL = "https://portal.autopilotmonitor.com";
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ export const SHARED_MANIFEST = {
|
||||
"maxNdjsonPayloadSizeMB": 5,
|
||||
"enablePerformanceCollector": true,
|
||||
"performanceCollectorIntervalSeconds": 30,
|
||||
"helloWaitTimeoutSeconds": 30,
|
||||
"helloWaitTimeoutSeconds": 300,
|
||||
"maxAuthFailures": null,
|
||||
"authFailureTimeoutMinutes": null,
|
||||
"agentMaxLifetimeMinutes": null,
|
||||
@@ -383,7 +383,7 @@ export const SHARED_MANIFEST = {
|
||||
"desktopDetectorNoCandidateTimeoutMinutes": 10,
|
||||
"enableAgentSelfMetrics": true,
|
||||
"agentSelfMetricsIntervalSeconds": 60,
|
||||
"helloWaitTimeoutSeconds": 30,
|
||||
"helloWaitTimeoutSeconds": 300,
|
||||
"agentMaxLifetimeMinutes": 360,
|
||||
"enableDeliveryOptimizationCollector": true,
|
||||
"deliveryOptimizationIntervalSeconds": 3,
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
"maxNdjsonPayloadSizeMB": 5,
|
||||
"enablePerformanceCollector": true,
|
||||
"performanceCollectorIntervalSeconds": 30,
|
||||
"helloWaitTimeoutSeconds": 30,
|
||||
"helloWaitTimeoutSeconds": 300,
|
||||
"maxAuthFailures": null,
|
||||
"authFailureTimeoutMinutes": null,
|
||||
"agentMaxLifetimeMinutes": null,
|
||||
@@ -379,7 +379,7 @@
|
||||
"desktopDetectorNoCandidateTimeoutMinutes": 10,
|
||||
"enableAgentSelfMetrics": true,
|
||||
"agentSelfMetricsIntervalSeconds": 60,
|
||||
"helloWaitTimeoutSeconds": 30,
|
||||
"helloWaitTimeoutSeconds": 300,
|
||||
"agentMaxLifetimeMinutes": 360,
|
||||
"enableDeliveryOptimizationCollector": true,
|
||||
"deliveryOptimizationIntervalSeconds": 3,
|
||||
@@ -5485,7 +5485,7 @@
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Seconds to wait for the Windows Hello wizard after ESP exit. Default: 30 seconds"
|
||||
"doc": "Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds"
|
||||
},
|
||||
{
|
||||
"name": "agentMaxLifetimeMinutes",
|
||||
@@ -22023,7 +22023,7 @@
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "Seconds to wait for the Windows Hello wizard after ESP exit Default: 30 seconds"
|
||||
"doc": "Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds"
|
||||
},
|
||||
{
|
||||
"name": "maxAuthFailures",
|
||||
@@ -22866,6 +22866,15 @@
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups."
|
||||
},
|
||||
{
|
||||
"name": "customerLabel",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Operator-created groups only: the name a managed customer sees for this access instead of the neutral \"Platform support\"; absent when none is set. The group name itself never reaches a customer."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -22997,7 +23006,7 @@
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "The owned group's name for a self-service delegation; the neutral operator label (\"Platform support\") for every operator-provisioned entry — internal group names never reach a customer."
|
||||
"doc": "The owned group's name for a self-service delegation; for an operator group its customer label, else the neutral \"Platform support\" (always for direct operator grants) — internal group names never reach a customer."
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
@@ -24307,7 +24316,7 @@
|
||||
},
|
||||
"UpdateTenantGroupRequest": {
|
||||
"kind": "object",
|
||||
"doc": "Body of PATCH global/tenant-groups/{groupId} — a rename.",
|
||||
"doc": "Body of PATCH global/tenant-groups/{groupId} — only the fields to change; at least one is required.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "name",
|
||||
@@ -24317,7 +24326,17 @@
|
||||
"nullable": true
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "New display name."
|
||||
"doc": "New display name (Global Admin UI only)."
|
||||
},
|
||||
{
|
||||
"name": "customerLabel",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "The name managed customers see for this operator group; an empty string clears it back to \"Platform support\". Rejected on self-service groups."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1043,7 +1043,7 @@ export interface CollectorConfiguration {
|
||||
enableAgentSelfMetrics: boolean;
|
||||
/** Interval in seconds for agent self-metrics snapshots. Default: 60 seconds */
|
||||
agentSelfMetricsIntervalSeconds: number;
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit. Default: 30 seconds */
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds */
|
||||
helloWaitTimeoutSeconds: number;
|
||||
/** Maximum agent lifetime in minutes. Safety net to prevent zombie agents. 0 = disabled (no lifetime limit). Default: 360 (6 hours). */
|
||||
agentMaxLifetimeMinutes: number;
|
||||
@@ -4810,7 +4810,7 @@ export interface TenantConfiguration {
|
||||
enablePerformanceCollector: boolean;
|
||||
/** Performance collector interval in seconds Default: 30 seconds */
|
||||
performanceCollectorIntervalSeconds: number;
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit Default: 30 seconds */
|
||||
/** Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds */
|
||||
helloWaitTimeoutSeconds: number;
|
||||
/** Maximum consecutive authentication failures (401/403) before the agent shuts down. null = use default (5). 0 = disabled (retry forever). */
|
||||
maxAuthFailures?: number | null;
|
||||
@@ -5004,6 +5004,8 @@ export interface TenantGroup {
|
||||
assignees: TenantGroupAssignment[];
|
||||
/** The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups. */
|
||||
ownerTenantId?: string;
|
||||
/** Operator-created groups only: the name a managed customer sees for this access instead of the neutral "Platform support"; absent when none is set. The group name itself never reaches a customer. */
|
||||
customerLabel?: string;
|
||||
}
|
||||
|
||||
/** One UPN→group assignment. PK=UPN, RK=groupId in storage. */
|
||||
@@ -5036,7 +5038,7 @@ export interface TenantManagerItem {
|
||||
/** The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants. */
|
||||
ownerTenantId?: string;
|
||||
ownerDomain?: string;
|
||||
/** The owned group's name for a self-service delegation; the neutral operator label ("Platform support") for every operator-provisioned entry — internal group names never reach a customer. */
|
||||
/** The owned group's name for a self-service delegation; for an operator group its customer label, else the neutral "Platform support" (always for direct operator grants) — internal group names never reach a customer. */
|
||||
name: string;
|
||||
/** self-service | operator */
|
||||
source: string;
|
||||
@@ -5339,10 +5341,12 @@ export interface UpdateTenantAppHomingResponse {
|
||||
probe: AppHomingProbeWire;
|
||||
}
|
||||
|
||||
/** Body of PATCH global/tenant-groups/{groupId} — a rename. */
|
||||
/** Body of PATCH global/tenant-groups/{groupId} — only the fields to change; at least one is required. */
|
||||
export interface UpdateTenantGroupRequest {
|
||||
/** New display name. */
|
||||
/** New display name (Global Admin UI only). */
|
||||
name?: string | null;
|
||||
/** The name managed customers see for this operator group; an empty string clears it back to "Platform support". Rejected on self-service groups. */
|
||||
customerLabel?: string | null;
|
||||
}
|
||||
|
||||
/** Response of PUT sessions/{sessionId}/annotations/{lane} when both verdict and note were empty and the lane was cleared. */
|
||||
|
||||
Reference in New Issue
Block a user