Compare commits

...

5 Commits

Author SHA1 Message Date
Oliver Kieselbach 8ec5a5debd offboarding: close the un-offboard race — config freeze + fail-closed wipe guard
While a tenant offboarding is queued or draining, the TenantConfiguration
tombstone (Disabled=true, 'Offboarding in progress') is the cascade's only
auth gate. A GA lifting Disabled mid-cascade re-opened the tenant against a
wipe that was still coming: data written afterwards was destroyed, and
sessions registered after enumeration survived as orphans while their event
partitions were wiped.

Three layers close it:
- PUT config/{tenantId} returns 409 while the row is the offboarding
  tombstone — for all callers, GA included
- TenantConfigPatchService refuses patch AND revert against the tombstone
  (a revert restores a pre-offboarding snapshot with Disabled=false — the
  exact race)
- TenantOffboardingHandler re-reads the config before the destructive
  post-drain phases and fail-closes with 'unoffboard_race' when the
  tombstone is gone but the wipe has not run (last line of defense)

Shared predicate: TenantOffboardFunction.IsOffboardingTombstone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:25:03 +02:00
Oliver Kieselbach ff35270d5d agent: legacy bootstrap-config without TenantIdWaitSeconds must not disable the wait
A bootstrap-config.json written before the TenantIdWaitSeconds property
existed deserialized the missing field to 0, which the runtime treated as
an explicit opt-out and skipped the 600s TenantId wait entirely — exactly
the wait Hybrid-Join devices need while their Entra registration lands.

TenantIdWaitSeconds is now nullable: missing/null means 'not configured'
and falls through to the agent default (600s); an explicit 0 remains the
legacy fast-fail opt-out. The install merge carries null through instead
of freezing the default into the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:24:50 +02:00
Oliver Kieselbach 312e849573 email: welcome + farewell templates to green branding
Header gradient, CTA button, and links move from the old blue
(#2563eb/#4f46e5) to the landing-page green palette (#1e8a4c/#33b161).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:11:23 +02:00
Oliver Kieselbach 7433df528d web: mark-led OG/Twitter image + PNG apple-touch-icon
The OG image was an unreadable shrunken hero at card size; replace it
with a centered mark-first design (big green shape, large wordmark,
single tagline). Safari cannot use SVG for apple-touch-icon and fell
back to a letter tile — point the icons config at the file-convention
apple-icon.png, now the green mark on white (180x180).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:17:23 +02:00
Claude d86ae18a5e distress: carry the W365 Cloud PC marker into Devices Not Registered
First W365 field run: the rejected Cloud PC appeared in the report as an
anonymous "Microsoft Corporation / Virtual Machine" row — nothing told the
admin that the fix is the Cloud PC validator, not an Autopilot-registration
hunt. The agent's CloudPcDetector verdict (Windows365 key AND
CloudManagedDesktopExtension service) now rides the pre-auth distress channel:

- Agent: DistressReporter captures isCloudPc at construction (optional ctor
  arg, test fakes stay source-compatible); BackendClientFactory feeds it from
  CloudPcDetector; DistressReport.IsCloudPc on the wire (~18 bytes, fits the
  1536-byte payload cap without a bump — comment updated)
- Backend: DistressReportEntry.IsCloudPc, table column + read mapping
  (legacy rows -> false), structured log + AgentDistressReport AI event key;
  GetDeviceNotRegisteredFunction aggregates sticky-true per serial (same OR
  semantics as the Sessions IsCloudPc merge) and the data-quality notice
  names the new self-reported field. GA global/distress-reports serializes
  the full entry and picks it up automatically.
- Web: Cloud PC badge in the Devices Not Registered table (same sky badge as
  the session table) + conditional hint pointing at the Windows 365 Cloud PC
  Validation toggle and the W365CloudPcValidation Graph add-on.
- Tests: sticky-true + no-marker aggregation cases; MakeEntry helper gains
  the optional flag. Docs: third transport rail in
  backend/cloudpc-device-validation.md + log entry.

Unverified like every distress field — triage context only, never an auth
input. Verified: web tsc clean, eslint 0 errors (file's single warning
pre-exists). .NET build/tests run in CI (no SDK in this sandbox).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv
2026-08-07 10:52:35 +00:00
31 changed files with 478 additions and 46 deletions
+10 -1
View File
@@ -101,7 +101,7 @@ installed `CloudManagedDesktopExtension` service (probed via its
`HKLM\SYSTEM\CurrentControlSet\Services` key; the agent deliberately has no
`System.ServiceProcess` dependency). SKIP-safe: any error resolves `false`.
Two independent transport rails, never an auth input:
Three independent transport rails, never an auth input:
- **Session metadata** — `SessionRegistration.IsCloudPc` →
Sessions table (sticky-true OR across re-registrations, like
@@ -110,6 +110,15 @@ Two independent transport rails, never an auth input:
MCP `search_sessions`/`query_raw_sessions`). Agent-reported and unverified —
can legitimately disagree with the server-derived `ValidatedBy = CloudPc`
(e.g. Cloud PC admitted via a stage that ran earlier).
- **Distress channel** — `DistressReport.IsCloudPc` (captured once at
`DistressReporter` construction, `BackendClientFactory`) rides every pre-auth
distress report → `DistressReportEntry.IsCloudPc` → the "Devices Not
Registered" report aggregates it **sticky-true** per serial
(`GetDeviceNotRegisteredFunction`) and the portal insight renders a Cloud PC
badge plus a hint pointing at the Cloud PC validation toggle + the
`W365CloudPcValidation` add-on. Same UNVERIFIED contract as all distress
fields; rows from older agents read false. Fits the existing 1536-byte
payload cap without a bump.
- **Decision engine** — the marker rides the `EnrollmentFactsObserved` payload
(`isCloudPc`), is recorded set-once for BOTH values in
`EnrollmentScenarioObservations.CloudPc`, and annotates the scenario profile
+4
View File
@@ -1,5 +1,9 @@
# Log
## 2026-08-07 (later)
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
## 2026-08-07
* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input.
@@ -132,10 +132,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
}
[Fact]
public void First_install_without_wait_arg_uses_agent_default_of_600()
public void First_install_without_wait_arg_persists_null_so_runtime_default_applies()
{
// Generic bootstrap path: PS1 calls `--install` without --tenant-id-wait,
// and there is no persisted config yet. Agent owns the default (600 s).
// and there is no persisted config yet. "Not configured" is persisted as null
// so BuildAgentConfiguration applies the agent-side default (600 s) — freezing
// a number into the file would decouple deployed devices from default changes.
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing: null,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
@@ -144,20 +146,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Null(merged.BootstrapToken);
Assert.Null(merged.TenantId);
Assert.Equal(600, merged.TenantIdWaitSeconds);
Assert.Null(merged.TenantIdWaitSeconds);
}
[Fact]
public void Existing_config_without_wait_field_treated_as_zero()
public void Legacy_config_without_wait_field_stays_null_on_plain_reinstall()
{
// BackCompat: pre-fix bootstrap-config.json has no TenantIdWaitSeconds field;
// Newtonsoft deserialises that to 0. New install with --tenant-id-wait 600
// overrides cleanly; no extra migration logic required.
// BackCompat: pre-feature bootstrap-config.json has no TenantIdWaitSeconds
// property → nullable deserialisation yields null. A reinstall that does not
// pass --tenant-id-wait must NOT turn that into 0 (which would silently
// disable the wait); null carries through and the runtime default applies.
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = 0,
TenantIdWaitSeconds = null,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
tenantIdArg: null!, tenantIdGiven: false,
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
Assert.Equal("tok-legacy", merged.BootstrapToken);
Assert.Equal("tenant-legacy", merged.TenantId);
Assert.Null(merged.TenantIdWaitSeconds);
}
[Fact]
public void Legacy_config_without_wait_field_takes_explicit_wait_arg()
{
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = null,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
@@ -170,5 +194,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Equal("tenant-legacy", merged.TenantId);
Assert.Equal(600, merged.TenantIdWaitSeconds);
}
[Fact]
public void Explicit_zero_opt_out_survives_redeploy_without_wait_arg()
{
// An admin's deliberate `--tenant-id-wait 0` must not be resurrected to a
// wait by a later redeploy that omits the flag.
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-abc",
TenantId = "tenant-xyz",
TenantIdWaitSeconds = 0,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
tenantIdArg: null!, tenantIdGiven: false,
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
Assert.Equal(0, merged.TenantIdWaitSeconds);
}
}
}
@@ -0,0 +1,90 @@
using AutopilotMonitor.Agent.V2;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Pins the TenantIdWaitSeconds resolution order in
/// <see cref="AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration"/>:
/// CLI arg &gt; persisted bootstrap-config.json value &gt; agent default (600 s).
/// The regression this guards: a legacy bootstrap-config.json written before the
/// TenantIdWaitSeconds property existed deserialises to null — that must fall
/// through to the 600 s default, NOT be treated as an explicit 0 opt-out
/// (which silently disabled the wait and broke Hybrid-AAD-join devices whose
/// registry TenantId lands minutes after first boot).
/// </summary>
public sealed class TenantIdWaitResolutionTests
{
private static AgentConfiguration Build(BootstrapConfigFile bootstrapConfig, params string[] args) =>
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
args: args,
tenantId: "tenant-t",
sessionId: "session-s",
bootstrapConfig: bootstrapConfig,
awaitConfig: null);
[Fact]
public void No_cli_and_no_bootstrap_config_uses_default_600()
{
var cfg = Build(bootstrapConfig: null);
Assert.Equal(600, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Legacy_bootstrap_config_with_null_wait_uses_default_600()
{
var legacy = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = null,
};
var cfg = Build(legacy);
Assert.Equal(600, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Explicit_zero_in_bootstrap_config_disables_wait()
{
var optedOut = new BootstrapConfigFile { TenantIdWaitSeconds = 0 };
var cfg = Build(optedOut);
Assert.Equal(0, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Nonzero_bootstrap_config_value_wins_over_default()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured);
Assert.Equal(1800, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Cli_arg_wins_over_bootstrap_config_value()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured, "--tenant-id-wait", "300");
Assert.Equal(300, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Cli_zero_wins_over_bootstrap_config_value()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured, "--tenant-id-wait", "0");
Assert.Equal(0, cfg.TenantIdWaitSeconds);
}
}
}
@@ -272,9 +272,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// CloudDomainJoin keys and re-probes on every change until either resolution
/// or this timeout. 0 = no wait, fast-fail on miss (legacy behaviour).
/// <para>
/// Set via the install-time CLI flag <c>--tenant-id-wait &lt;sec&gt;</c>, persisted
/// to <c>bootstrap-config.json</c>. The PS1 bootstrap script owns the production
/// default; the agent itself never assumes one.
/// Set via the CLI flag <c>--tenant-id-wait &lt;sec&gt;</c> or a persisted
/// <c>bootstrap-config.json</c> value; when neither supplies a value the agent
/// applies its own default of 600 s in BuildAgentConfiguration. The 0 default
/// here is only the POCO baseline, not the effective runtime default.
/// </para>
/// </summary>
public int TenantIdWaitSeconds { get; set; } = 0;
@@ -16,8 +16,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// <summary>
/// TenantId-wait timeout in seconds, persisted from the <c>--tenant-id-wait</c>
/// install-time arg. Read by AgentBootstrap when the registry probe finds no
/// TenantId on first try. 0 / missing = no wait, legacy fast-fail.
/// TenantId on first try. 0 = explicit opt-out (legacy fast-fail).
/// Null / missing (files written before this property existed) = not configured —
/// the agent-side default (600 s) applies. Nullable so a legacy file without the
/// property cannot masquerade as an explicit 0 and silently disable the wait.
/// </summary>
public int TenantIdWaitSeconds { get; set; }
public int? TenantIdWaitSeconds { get; set; }
}
}
@@ -56,6 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
private DateTime? _lastReportTime;
private int _reportCount;
// Local W365 marker verdict, captured once at construction (same UNVERIFIED contract
// as the hardware fields). Optional ctor arg so test fakes stay source-compatible.
private readonly bool _isCloudPc;
public DistressReporter(
string baseUrl,
string tenantId,
@@ -64,7 +68,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
string serialNumber,
string agentVersion,
AgentLogger logger,
X509Certificate2 clientCertificate = null)
X509Certificate2 clientCertificate = null,
bool isCloudPc = false)
{
_tenantId = tenantId;
_manufacturer = manufacturer;
@@ -72,6 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
_serialNumber = serialNumber;
_agentVersion = agentVersion;
_logger = logger;
_isCloudPc = isCloudPc;
// Capture cert context. Each field is independently nullable so the backend can tell
// "agent had no cert at all" (NoCertInStore + all-null fields) from "agent had a cert
@@ -169,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
HttpStatusCode = httpStatusCode,
Message = Truncate(message, 256),
Timestamp = DateTime.UtcNow,
IsCloudPc = _isCloudPc,
CertSourceState = _capturedCertState,
CertThumbprint = _capturedCertThumbprint,
CertSubject = Truncate(_capturedCertSubject, 96),
@@ -135,7 +135,7 @@ namespace AutopilotMonitor.Agent.V2
$"{(bootstrapTokenGiven ? "*" : "")}, " +
$"tenantId={(string.IsNullOrEmpty(merged.TenantId) ? "no" : "yes")}" +
$"{(tenantIdGiven ? "*" : "")}, " +
$"tenantIdWait={merged.TenantIdWaitSeconds}s" +
$"tenantIdWait={(merged.TenantIdWaitSeconds is int w ? $"{w}s" : "default")}" +
$"{(tenantIdWaitGiven ? "*" : "")})." +
" Star = explicitly set on this --install invocation, others were preserved from prior config.");
}
@@ -361,9 +361,12 @@ namespace AutopilotMonitor.Agent.V2
{
BootstrapToken = bootstrapTokenGiven ? bootstrapTokenArg : existing?.BootstrapToken,
TenantId = tenantIdGiven ? tenantIdArg : existing?.TenantId,
// Not given → carry the prior value through, INCLUDING null. Persisting a
// number here would freeze the agent-side default into the file; persisting
// null keeps "not configured" distinguishable from an explicit opt-out (0).
TenantIdWaitSeconds = tenantIdWaitGiven
? tenantIdWaitSeconds
: (existing?.TenantIdWaitSeconds ?? 600),
: existing?.TenantIdWaitSeconds,
};
}
@@ -339,13 +339,15 @@ namespace AutopilotMonitor.Agent.V2
// over the agent-side default (600 s). Hybrid-AAD-joined devices typically
// need ~5 min for the AAD device cert to land in the registry; 600 s leaves
// headroom. Pass `--tenant-id-wait 0` to opt out (legacy fast-fail).
// A bootstrap-config.json whose TenantIdWaitSeconds is null (file predates the
// property) is "not configured", NOT an opt-out — the default must still apply.
var tenantIdWaitRaw = GetArgValue(args, "--tenant-id-wait");
const int tenantIdWaitDefaultSeconds = 600;
int tenantIdWaitSeconds = tenantIdWaitDefaultSeconds;
if (!string.IsNullOrEmpty(tenantIdWaitRaw) && int.TryParse(tenantIdWaitRaw, out var parsedTenantIdWait))
tenantIdWaitSeconds = parsedTenantIdWait;
else if (bootstrapConfig != null)
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds;
else if (bootstrapConfig?.TenantIdWaitSeconds != null)
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds.Value;
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
@@ -102,7 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
serialNumber: hardware.SerialNumber,
agentVersion: agentVersion,
logger: logger,
clientCertificate: clientCertificate);
clientCertificate: clientCertificate,
isCloudPc: CloudPcDetector.DetectIsCloudPc());
var emergencyReporter = new EmergencyReporter(
apiClient: backendApiClient,
@@ -164,12 +164,49 @@ public class GetDeviceNotRegisteredFunctionTests
Assert.Equal(3, (int)item.attemptCount);
}
// =========================================================================
// Cloud PC marker aggregation
// =========================================================================
[Fact]
public void BuildAggregatedResult_IsCloudPc_StickyTrueAcrossGroup()
{
// One old agent (no field → false) + one new agent reporting the W365 marker:
// the aggregate must say Cloud PC even though the MOST RECENT report is false —
// same OR semantics as the Sessions-table IsCloudPc merge.
var reports = new List<DistressReportEntry>
{
MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0, isCloudPc: true),
MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0.AddMinutes(5), isCloudPc: false),
};
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.True((bool)item.isCloudPc);
}
[Fact]
public void BuildAggregatedResult_IsCloudPc_FalseWhenNoReportCarriesMarker()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0),
};
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.False((bool)item.isCloudPc);
}
// =========================================================================
// Helpers
// =========================================================================
private static DistressReportEntry MakeEntry(
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) =>
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt,
bool isCloudPc = false) =>
new()
{
TenantId = "test-tenant",
@@ -179,6 +216,7 @@ public class GetDeviceNotRegisteredFunctionTests
SerialNumber = serial,
IngestedAt = ingestedAt,
AgentTimestamp = ingestedAt,
IsCloudPc = isCloudPc,
};
private static dynamic ToDynamic(object obj)
@@ -42,6 +42,7 @@ public sealed class TenantCustomsArchiveContractTests
reEnqueuer: null!,
opsEvents: null!,
customsArchive: archive,
configRepo: null!,
farewellEmail: new FakeOffboardFarewellEmailSender(),
logger: NullLogger<TenantOffboardingHandler>.Instance);
}
@@ -9,6 +9,7 @@ using Azure;
using Azure.Data.Tables;
using Azure.Storage.Blobs;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Functions.Services.Notifications;
@@ -301,6 +302,47 @@ public class TenantOffboardingHandlerDrainContractTests
"Phase 2.G must delete the Expectations blob as its last step");
}
[Fact]
public async Task UnoffboardRace_ConfigNoLongerTombstone_FailsClosed_NoSafeWipe()
{
// 2.D-guard: an operator re-enabled the tenant while the cascade was queued/draining
// (the config write paths refuse this, but the guard is the last line of defense).
// Wiping now would destroy data of a live tenant → fail-closed, zero wipes.
var harness = Harness.New();
harness.ConfigRepo
.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, Disabled = false });
await Assert.ThrowsAsync<InvalidOperationException>(() =>
harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
Assert.Equal("Failed", harness.Repo.Markers[TenantId].Status);
Assert.Equal("unoffboard_race", harness.Repo.Markers[TenantId].FailedPhase);
Assert.Equal("Failed", harness.Repo.History[HistoryRowKey].Status);
Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
}
[Fact]
public async Task UnoffboardGuard_TombstoneStillInPlace_ProceedsToWipeAndCompleted()
{
// The normal case: Phase 1's tombstone is untouched → the guard must NOT get in
// the way of a legitimate offboarding.
var harness = Harness.New();
harness.ConfigRepo
.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(new TenantConfiguration
{
TenantId = TenantId,
Disabled = true,
DisabledReason = TenantOffboardFunction.OffboardingDisabledReason,
});
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
Assert.True(harness.SafeWipeProbe.WipeCallCount > 0);
}
[Fact]
public async Task SessionNotFound_CountsAsSatisfied_NoDrainProbeCall()
{
@@ -393,6 +435,7 @@ public class TenantOffboardingHandlerDrainContractTests
public Mock<OffboardingSessionEnumerator> Enumerator { get; }
public CountingSafeWipeService SafeWipeProbe { get; } = new();
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public Mock<IConfigRepository> ConfigRepo { get; } = new();
public List<string> EnumeratorYields { get; } = new();
public Exception? EnumeratorThrow { get; set; }
@@ -450,6 +493,7 @@ public class TenantOffboardingHandlerDrainContractTests
h.ReEnqueuer.Object,
opsService,
Mock.Of<ITenantCustomsArchiveRepository>(),
h.ConfigRepo.Object,
new FakeOffboardFarewellEmailSender(),
NullLogger<TenantOffboardingHandler>.Instance);
@@ -622,10 +666,11 @@ public class TenantOffboardingHandlerDrainContractTests
ITenantOffboardingEnqueuer reEnqueuer,
OpsEventService opsEvents,
ITenantCustomsArchiveRepository customsArchive,
IConfigRepository configRepo,
IOffboardFarewellEmailSender farewellEmail,
ILogger<TenantOffboardingHandler> logger)
: base(auditRepo, enumerator, cascadeEnqueuer, expectations, drainProbe, safeWipe,
storage, maintenance, reEnqueuer, opsEvents, customsArchive, farewellEmail, logger)
storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail, logger)
{
}
@@ -358,6 +358,7 @@ public class TenantOffboardingHandlerOrderingTests
h.ReEnqueuer.Object,
BuildOpsService(),
Mock.Of<ITenantCustomsArchiveRepository>(),
Mock.Of<IConfigRepository>(),
h.FarewellEmail,
NullLogger<TenantOffboardingHandler>.Instance);
@@ -459,9 +460,9 @@ public class TenantOffboardingHandlerOrderingTests
IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IOffboardFarewellEmailSender fe,
ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, fe, log) { }
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
@@ -245,6 +245,7 @@ public class TenantOffboardingWorkerPoisonTests
Mock.Of<ITenantOffboardingEnqueuer>(),
BuildOpsService(),
Mock.Of<ITenantCustomsArchiveRepository>(),
Mock.Of<IConfigRepository>(),
new FakeOffboardFarewellEmailSender(),
NullLogger<TenantOffboardingHandler>.Instance);
}
@@ -255,9 +256,9 @@ public class TenantOffboardingWorkerPoisonTests
IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IOffboardFarewellEmailSender fe,
ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, fe, log) { }
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -154,6 +155,68 @@ public class TenantConfigPatchServiceTests
Assert.Null(harness.Current!.ContactEmail);
}
// ── Mid-offboarding freeze ──────────────────────────────────────────────
[Fact]
public async Task Patch_OffboardingTombstone_IsFrozen_EvenForGa()
{
// While the row is the offboarding tombstone, the cascade owns it: a GA patch
// lifting Disabled would un-gate agents against a tenant whose partitions are
// queued for deletion (un-offboard race).
var stored = Stored();
stored.Disabled = true;
stored.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
var harness = new Harness(stored);
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("disabled", false)), Ga, "mcp-patch", "trying to un-offboard");
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Contains("offboarding", outcome.Error, StringComparison.OrdinalIgnoreCase);
Assert.True(harness.Current!.Disabled, "tombstone must be untouched");
Assert.Equal(0, harness.ReplaceCalls);
}
[Fact]
public async Task Patch_OrdinarySuspension_IsNotFrozen()
{
// The freeze is specific to the offboarding tombstone — a normally suspended
// tenant (different DisabledReason) stays fully patchable.
var stored = Stored();
stored.Disabled = true;
stored.DisabledReason = "Manual suspension";
var harness = new Harness(stored);
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 60)), Ga, "mcp-patch", null);
Assert.True(outcome.Success);
Assert.Equal(60, harness.Current!.DataRetentionDays);
}
[Fact]
public async Task Revert_OffboardingTombstone_IsFrozen()
{
// A revert restores a pre-offboarding snapshot with Disabled=false — the exact
// un-offboard race. Build a backup while the tenant is healthy, then tombstone.
var harness = new Harness(Stored());
var patched = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
Assert.True(patched.Success);
harness.Current!.Disabled = true;
harness.Current.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
var outcome = await harness.Sut.RevertAsync(
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Contains("offboarding", outcome.Error, StringComparison.OrdinalIgnoreCase);
Assert.True(harness.Current.Disabled, "tombstone must be untouched");
}
[Fact]
public async Task Patch_NoOp_ValueEqualsStored_NoBackupNoWrite()
{
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["AgentTimestamp"] = entry.AgentTimestamp,
["IngestedAt"] = entry.IngestedAt,
["SourceIp"] = Truncate(entry.SourceIp, 45), // max IPv6 length
["IsCloudPc"] = entry.IsCloudPc,
["CertSourceState"] = Truncate(entry.CertSourceState, 32),
["CertThumbprint"] = Truncate(entry.CertThumbprint, 40),
["CertSubject"] = Truncate(entry.CertSubject, 96),
@@ -125,6 +126,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
AgentTimestamp = entity.GetDateTimeOffset("AgentTimestamp")?.UtcDateTime ?? DateTime.MinValue,
IngestedAt = entity.GetDateTimeOffset("IngestedAt")?.UtcDateTime ?? DateTime.MinValue,
SourceIp = entity.GetString("SourceIp"),
IsCloudPc = entity.GetBoolean("IsCloudPc") ?? false,
CertSourceState = entity.GetString("CertSourceState"),
CertThumbprint = entity.GetString("CertThumbprint"),
CertSubject = entity.GetString("CertSubject"),
@@ -517,6 +517,17 @@ public class TenantOffboardFunction
/// </summary>
internal const string OffboardingDisabledReason = "Offboarding in progress";
/// <summary>
/// True when the row is the offboarding tombstone written by
/// <c>EnsureTenantDisabledAsync</c>. Shared predicate for every config writer and
/// wiper that must distinguish the mid-offboarding freeze from an ordinary
/// suspended (or re-onboarded) tenant: while this is true, the tenant's
/// configuration is frozen and the offboarding cascade owns the row.
/// </summary>
internal static bool IsOffboardingTombstone(TenantConfiguration? config) =>
config is { Disabled: true }
&& string.Equals(config.DisabledReason, OffboardingDisabledReason, StringComparison.Ordinal);
/// <summary>
/// Result of <see cref="EnsureTenantDisabledAsync"/>. Carries both whether a fresh
/// write happened AND the tombstone's <c>LastUpdated</c> timestamp so the resume-path
@@ -3,6 +3,7 @@ using System.IO;
using System.Net;
using System.Threading.Tasks;
using System.Collections.Generic;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -71,6 +72,23 @@ namespace AutopilotMonitor.Functions.Functions.Config
// before validation/save and (2) protect GA-only fields below.
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// Mid-offboarding freeze: the Disabled tombstone written by TenantOffboardFunction
// is the cascade's ONLY auth gate. A PUT that lifts it (or edits anything else)
// races the queued wipe — data written afterwards gets destroyed, and agents
// would be un-gated against a tenant whose partitions are being deleted. Refuse
// for ALL callers, GA included; once the cascade completes the row is deleted
// and re-onboarding starts from a fresh default config anyway.
if (TenantOffboardFunction.IsOffboardingTombstone(existingConfig))
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new
{
success = false,
message = "Tenant offboarding is in progress — the configuration is frozen until the cascade completes."
});
return conflict;
}
// Defense-in-depth: a read-only GlobalReader is served a redacted config (secrets replaced
// with the ***REDACTED*** sentinel). If such a view is ever round-tripped back on a save,
// never persist the placeholder — restore the real secret from the stored config. (Own-tenant
@@ -44,6 +44,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// Strict limits for the unauthenticated endpoint.
// MaxContentLength bumped from 1024 -> 1536 to accommodate the V2 cert-context fields
// (Thumbprint + Subject + Issuer + NotBefore/After + SourceState ~430 bytes worst-case).
// The IsCloudPc bool (2026-08) adds ≤ 18 bytes ("\"IsCloudPc\":false,") — well within
// the existing headroom, no bump needed.
// Rate-limiting (per-IP + per-tenant + global circuit breaker) remains the primary
// DoS defense — the size cap is a payload-shape gate, not the throttle.
internal const int MaxContentLength = 1536;
@@ -204,6 +206,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
AgentTimestamp = report.Timestamp,
IngestedAt = DateTime.UtcNow,
SourceIp = clientIp,
IsCloudPc = report.IsCloudPc,
CertSourceState = certSourceState,
CertThumbprint = certThumbprint,
CertSubject = certSubject,
@@ -297,8 +300,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// Structured log (Warning, not Critical — data is unverified)
_logger.LogWarning(
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
report.ErrorType, tenantId, manufacturer, model, serialNumber,
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
report.HttpStatusCode, agentVersion, certSourceState ?? "n/a", certThumbprint ?? "n/a", message);
// Custom event for KQL queries:
@@ -315,6 +318,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
["Message"] = message ?? string.Empty,
["AgentTimestamp"] = report.Timestamp.ToString("O"),
["SourceIp"] = clientIp,
["IsCloudPc"] = report.IsCloudPc.ToString(),
["CertSourceState"] = certSourceState ?? string.Empty,
["CertThumbprint"] = certThumbprint ?? string.Empty,
["CertSubject"] = certSubject ?? string.Empty,
@@ -49,7 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
success = true,
aggregated,
totalRawReports,
dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, and model values are self-reported by devices."
dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, model, and the Cloud PC marker are self-reported by devices."
});
}
catch (Exception ex)
@@ -81,6 +81,10 @@ namespace AutopilotMonitor.Functions.Functions.Reports
serialNumber = mostRecent.SerialNumber ?? "",
manufacturer = mostRecent.Manufacturer ?? "",
model = mostRecent.Model ?? "",
// Sticky-true across the group (same OR semantics as the Sessions-table
// IsCloudPc merge): older agents in the mix report false for the same
// device, and a once-seen W365 marker must not flap back to false.
isCloudPc = g.Any(r => r.IsCloudPc),
attemptCount = g.Count(),
firstSeen = g.Min(r => r.IngestedAt),
lastSeen = g.Max(r => r.IngestedAt)
@@ -39,9 +39,9 @@ public static class EmailTemplates
<!-- Header -->
<tr>
<td style=""background:linear-gradient(135deg,#2563eb,#4f46e5); padding:32px 40px; text-align:center;"">
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
<p style=""color:#c7d2fe; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
</td>
</tr>
@@ -68,8 +68,8 @@ public static class EmailTemplates
</p>
<ul style=""color:#374151; font-size:14px; line-height:1.8; margin:0 0 24px; padding-left:20px;"">
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Connect on LinkedIn</a></li>
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Connect on LinkedIn</a></li>
</ul>
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 24px;"">
@@ -117,9 +117,9 @@ public static class EmailTemplates
<!-- Header -->
<tr>
<td style=""background:linear-gradient(135deg,#2563eb,#4f46e5); padding:32px 40px; text-align:center;"">
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
<p style=""color:#c7d2fe; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
</td>
</tr>
@@ -140,7 +140,7 @@ public static class EmailTemplates
<!-- CTA Button -->
<table cellpadding=""0"" cellspacing=""0"" style=""margin:0 auto 32px;"">
<tr>
<td style=""background-color:#2563eb; border-radius:8px;"">
<td style=""background-color:#1e8a4c; border-radius:8px;"">
<a href=""{Constants.DocsBaseUrl}"" target=""_blank""
style=""display:inline-block; padding:14px 32px; color:#ffffff; font-size:15px; font-weight:600; text-decoration:none;"">
View Documentation
@@ -164,8 +164,8 @@ public static class EmailTemplates
</p>
<ul style=""color:#374151; font-size:14px; line-height:1.8; margin:0 0 24px; padding-left:20px;"">
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Connect on LinkedIn</a></li>
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Connect on LinkedIn</a></li>
</ul>
<p style=""color:#6b7280; font-size:14px; line-height:1.6; margin:0;"">
@@ -5,6 +5,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -167,6 +168,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
private readonly ITenantOffboardingEnqueuer _reEnqueuer;
private readonly OpsEventService _opsEvents;
private readonly ITenantCustomsArchiveRepository _customsArchive;
private readonly IConfigRepository _configRepo;
private readonly IOffboardFarewellEmailSender _farewellEmail;
private readonly ILogger<TenantOffboardingHandler> _logger;
@@ -182,6 +184,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
ITenantOffboardingEnqueuer reEnqueuer,
OpsEventService opsEvents,
ITenantCustomsArchiveRepository customsArchive,
IConfigRepository configRepo,
IOffboardFarewellEmailSender farewellEmail,
ILogger<TenantOffboardingHandler> logger)
{
@@ -196,6 +199,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
_reEnqueuer = reEnqueuer;
_opsEvents = opsEvents;
_customsArchive = customsArchive;
_configRepo = configRepo;
_farewellEmail = farewellEmail;
_logger = logger;
}
@@ -582,6 +586,27 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
private async Task RunPostDrainPhasesAsync(
OffboardingHistoryEntry history, string tenantId, CancellationToken ct)
{
// 2.D-guard — un-offboard race check. The destructive phases below are only
// legal while the TenantConfiguration row is still the Phase-1 offboarding
// tombstone (or already deleted by a previous 2.F-final on this resume path;
// null passes). If an operator re-enabled the tenant mid-cascade — the config
// write paths refuse that, but this is the last line of defense — wiping now
// would destroy data of a live tenant, and sessions registered after the 2.B
// enumeration would survive as orphan rows while their Events/Signals
// partitions get wiped. Fail-closed; the Failed marker + OpsEvent route the
// conflict to an operator instead.
var currentConfig = await _configRepo.GetTenantConfigurationAsync(tenantId);
if (currentConfig != null && !TenantOffboardFunction.IsOffboardingTombstone(currentConfig))
{
var msg =
$"TenantConfiguration is no longer the offboarding tombstone " +
$"(Disabled={currentConfig.Disabled}, Reason={currentConfig.DisabledReason ?? "<null>"}) " +
"— refusing to wipe a re-enabled tenant.";
await FailAsync(history, tenantId, "unoffboard_race", msg, ct);
throw new InvalidOperationException(
$"TenantOffboarding fail-closed for tenant={tenantId} phase=unoffboard_race");
}
// 2.D-archive (PR3.B §3) — Archive then wipe GatherRules / AnalyzeRules /
// ImeLogPatterns. Fail-loud: a row that cannot be archived must NOT be wiped.
// After the third iteration if the source still has rows, throw with
@@ -4,6 +4,7 @@ using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -185,6 +186,14 @@ namespace AutopilotMonitor.Functions.Services
return PatchOutcome.Fail(PatchFailure.NotFound, $"Tenant {tenantId} has no configuration row.");
var (initial, etag) = read.Value;
// Mid-offboarding freeze: while the row is the offboarding tombstone, the
// cascade owns it. Any patch — a GA lifting Disabled included — would race
// the queued wipe: data written afterwards gets destroyed, and sessions
// registered after enumeration would survive as orphans.
if (TenantOffboardFunction.IsOffboardingTombstone(initial))
return PatchOutcome.Fail(PatchFailure.ValidationFailed,
"Tenant offboarding is in progress — the configuration is frozen until the cascade completes.");
// Patch onto a deep clone; MissingMemberHandling.Error is belt-and-braces on
// top of the key gate above. JSON null = explicit clear; omitted = untouched;
// camelCase input binds case-insensitively to the PascalCase model.
@@ -275,6 +284,12 @@ namespace AutopilotMonitor.Functions.Services
return PatchOutcome.Fail(PatchFailure.NotFound, $"Tenant {tenantId} has no configuration row.");
var (current, etag) = read.Value;
// Same mid-offboarding freeze as ApplyFieldPatchAsync: a revert restores a
// pre-offboarding snapshot with Disabled=false — the exact un-offboard race.
if (TenantOffboardFunction.IsOffboardingTombstone(current))
return PatchOutcome.Fail(PatchFailure.ValidationFailed,
"Tenant offboarding is in progress — the configuration is frozen until the cascade completes.");
var candidate = DeepClone(target);
// Identity is never restored from a snapshot; protected/system-owned fields
// only with the explicit GA opt-in.
@@ -30,6 +30,12 @@ namespace AutopilotMonitor.Shared.DataAccess
public DateTime IngestedAt { get; set; }
public string? SourceIp { get; set; }
/// <summary>
/// Agent-reported W365 marker verdict (CloudPcDetector). UNVERIFIED like every distress
/// field; false for rows written before the field existed.
/// </summary>
public bool IsCloudPc { get; set; }
// Cert-context fields (V2 agents only). All optional; legacy entries leave these null.
// Format-validated and length-capped at ingest; treat as UNVERIFIED claims.
public string? CertSourceState { get; set; }
@@ -128,6 +128,15 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
/// <summary>
/// True when the agent's local W365 marker check (<c>CloudPcDetector</c>: Windows365
/// registry key AND CloudManagedDesktopExtension service) identified the device as a
/// Windows 365 Cloud PC. Unverified like all distress fields — pure triage context:
/// a rejected Cloud PC points the admin at the Cloud PC validation toggle instead of
/// a futile Autopilot-registration hunt. False for older agents that predate the field.
/// </summary>
public bool IsCloudPc { get; set; }
/// <summary>
/// Agent's classification of the cert state at the time of the failing call. V2 agents only.
/// Optional; left null/Unknown by V1 agents. Validated server-side with <c>Enum.IsDefined</c>.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+3 -1
View File
@@ -80,7 +80,9 @@ export const metadata: Metadata = {
icons: {
icon: "/icon.svg",
shortcut: "/icon.svg",
apple: "/icon.svg",
// Safari cannot use SVG for apple-touch-icon; must be an opaque PNG
// (served by the app/apple-icon.png file convention).
apple: "/apple-icon.png",
},
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 161 KiB

@@ -8,6 +8,8 @@ interface AggregatedNotRegistered {
serialNumber: string;
manufacturer: string;
model: string;
/** Agent-reported W365 marker verdict (unverified, like all distress fields). Absent on older backends. */
isCloudPc?: boolean;
attemptCount: number;
firstSeen: string;
lastSeen: string;
@@ -145,14 +147,24 @@ export default function NotRegisteredDevicesInsights({
{/* Data quality notice + table (only when devices exist) */}
{hasDevices && (
<>
<div className="px-6 pt-4">
<div className="px-6 pt-4 space-y-3">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
This data comes from pre-authentication distress signals. Serial number, manufacturer, and
model values are self-reported and unverified. Use it to sanity-check whether your enrollment
scoping/assignment covers these devices.
This data comes from pre-authentication distress signals. Serial number, manufacturer,
model, and the Cloud PC marker are self-reported and unverified. Use it to sanity-check
whether your enrollment scoping/assignment covers these devices.
</p>
</div>
{data!.aggregated.some((row) => row.isCloudPc) && (
<div className="bg-sky-50 border border-sky-200 rounded-lg p-3">
<p className="text-xs text-sky-800">
Devices marked <strong>Cloud PC</strong> identify as Windows 365 Cloud PCs. Cloud PCs are
never Autopilot-registered — enable <strong>Windows 365 Cloud PC Validation</strong> above
(and grant the <strong>W365CloudPcValidation</strong> add-on under Optional Graph
capabilities) so they can be admitted.
</p>
</div>
)}
</div>
<div className="p-6">
@@ -173,7 +185,17 @@ export default function NotRegisteredDevicesInsights({
<tr key={`${row.serialNumber}|${idx}`}>
<td className="py-3 pr-4 font-mono text-gray-900">{row.serialNumber || "–"}</td>
<td className="py-3 pr-4 text-gray-700">{row.manufacturer || "–"}</td>
<td className="py-3 pr-4 text-gray-700">{row.model || "–"}</td>
<td className="py-3 pr-4 text-gray-700">
{row.model || "–"}
{row.isCloudPc && (
<span
className="ml-2 px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full bg-sky-100 text-sky-800"
title="Windows 365 Cloud PC (agent-detected W365 markers, unverified)"
>
Cloud PC
</span>
)}
</td>
<td className="py-3 pr-4 text-right font-mono text-gray-900">{row.attemptCount}</td>
<td className="py-3 pr-4 text-gray-500 whitespace-nowrap">{formatDate(row.firstSeen)}</td>
<td className="py-3 text-gray-500 whitespace-nowrap">{formatDate(row.lastSeen)}</td>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 161 KiB