feat(agent): backport V1 TenantIdResolver with CloudDomainJoin fallback

Mirrors the V2 resolver shipped in fc950757 to V1 so the legacy rail
gets the same OOBE-race protection. Probe order: Enrollments registry
→ CloudDomainJoin\TenantInfo → CloudDomainJoin\JoinInfo, source-tagged
log line on hit, full diagnostic block on miss (which roots exist,
which enrollment GUIDs were seen with their EnrollmentType /
EnrollmentState, which JoinInfo thumbprints).

Removes the inline GetTenantIdFromRegistry helper from V1 Program.cs
and routes all three callsites (LoadConfiguration, await-enrollment
re-resolve, pre-flight bail) through the new resolver. The bail site
re-probes with a logger when the await branch was skipped, so the
diagnostic block lands in the agent log on a real miss instead of
just the generic "registry empty" error.

Updates a stale doc comment in V2 Program.GatherRulesMode.cs that
referenced the removed V1 method name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-30 19:13:59 +02:00
parent 5d03c6fbff
commit 99ec25175d
6 changed files with 307 additions and 46 deletions
@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.Core.Logging;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.Core.Security
{
/// <summary>
/// Resolves the device's AAD TenantId from one of several Windows registry signals.
/// V1 backport of the V2 resolver — same probe order, same diagnostic logging.
/// <para>
/// Probe order, first non-empty hit wins:
/// </para>
/// <list type="number">
/// <item><description>
/// <c>HKLM\SOFTWARE\Microsoft\Enrollments\{guid}</c> with <c>EnrollmentType=6</c>
/// (Intune MDM) and <c>AADTenantID</c>. Authoritative once MDM enrollment finished.
/// </description></item>
/// <item><description>
/// <c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo\{TenantId}</c>
/// — the sub-key name itself is the TenantId GUID. Written by AAD join, which in
/// Autopilot/OOBE happens before MDM enrollment.
/// </description></item>
/// <item><description>
/// <c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\{thumbprint}</c>
/// value <c>TenantId</c>. Same AAD-join phase as TenantInfo, used as a secondary
/// reading because some flows populate one but not the other.
/// </description></item>
/// </list>
/// <para>
/// Returns <c>null</c> when no source carries a TenantId. Never throws. On miss the
/// resolver dumps a diagnostic summary of which keys/values it saw so the bootstrap
/// log carries enough evidence to classify "device truly not enrolled" vs. "agent
/// fired before enrollment finished writing".
/// </para>
/// </summary>
public static class TenantIdResolver
{
private const string EnrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
private const string CloudDomainJoinTenantInfoPath = @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo";
private const string CloudDomainJoinJoinInfoPath = @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo";
private const int IntuneMdmEnrollmentType = 6;
public const string SourceEnrollmentsRegistry = "enrollments_registry";
public const string SourceCloudDomainJoinTenantInfo = "cloud_domain_join_tenant_info";
public const string SourceCloudDomainJoinJoinInfo = "cloud_domain_join_join_info";
/// <summary>
/// Tries each probe in order and returns the first non-empty TenantId.
/// Logs the winning source at Info level. On full miss, logs a diagnostic
/// summary at Warning level. Returns <c>null</c> when nothing resolves.
/// </summary>
public static string Resolve(AgentLogger logger = null)
{
var diagnostics = new ResolverDiagnostics();
var fromEnrollments = TryEnrollmentsRegistry(diagnostics);
if (!string.IsNullOrEmpty(fromEnrollments))
{
logger?.Info($"TenantIdResolver: resolved TenantId={fromEnrollments} from {SourceEnrollmentsRegistry}.");
return fromEnrollments;
}
var fromTenantInfo = TryCloudDomainJoinTenantInfo(diagnostics);
if (!string.IsNullOrEmpty(fromTenantInfo))
{
logger?.Info($"TenantIdResolver: resolved TenantId={fromTenantInfo} from {SourceCloudDomainJoinTenantInfo} (Enrollments registry had no Intune MDM hit).");
return fromTenantInfo;
}
var fromJoinInfo = TryCloudDomainJoinJoinInfo(diagnostics);
if (!string.IsNullOrEmpty(fromJoinInfo))
{
logger?.Info($"TenantIdResolver: resolved TenantId={fromJoinInfo} from {SourceCloudDomainJoinJoinInfo} (Enrollments registry + CloudDomainJoin\\TenantInfo had no hit).");
return fromJoinInfo;
}
LogResolutionMiss(logger, diagnostics);
return null;
}
private static string TryEnrollmentsRegistry(ResolverDiagnostics diag)
{
try
{
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(EnrollmentsKeyPath))
{
if (enrollmentsKey == null)
{
diag.EnrollmentsRootMissing = true;
return null;
}
foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames())
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid))
{
if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
int? typeValue = null;
if (enrollmentType != null)
{
try { typeValue = Convert.ToInt32(enrollmentType); }
catch { /* unparseable — record as null */ }
}
var aadTenantId = enrollmentKey.GetValue("AADTenantID")?.ToString();
var enrollmentState = enrollmentKey.GetValue("EnrollmentState")?.ToString();
diag.EnrollmentsSeen.Add(new EnrollmentSnapshot
{
Guid = enrollmentGuid,
EnrollmentType = typeValue,
EnrollmentState = enrollmentState,
HasAadTenantId = !string.IsNullOrEmpty(aadTenantId),
});
if (typeValue == IntuneMdmEnrollmentType && !string.IsNullOrEmpty(aadTenantId))
return aadTenantId;
}
}
}
}
catch (Exception ex)
{
diag.EnrollmentsProbeError = ex.GetType().Name + ": " + ex.Message;
}
return null;
}
private static string TryCloudDomainJoinTenantInfo(ResolverDiagnostics diag)
{
try
{
using (var tenantInfoKey = Registry.LocalMachine.OpenSubKey(CloudDomainJoinTenantInfoPath))
{
if (tenantInfoKey == null)
{
diag.TenantInfoRootMissing = true;
return null;
}
var subKeys = tenantInfoKey.GetSubKeyNames();
diag.TenantInfoSubKeys.AddRange(subKeys);
foreach (var subKey in subKeys)
{
if (Guid.TryParse(subKey, out _))
return subKey;
}
}
}
catch (Exception ex)
{
diag.TenantInfoProbeError = ex.GetType().Name + ": " + ex.Message;
}
return null;
}
private static string TryCloudDomainJoinJoinInfo(ResolverDiagnostics diag)
{
try
{
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(CloudDomainJoinJoinInfoPath))
{
if (joinInfoKey == null)
{
diag.JoinInfoRootMissing = true;
return null;
}
foreach (var thumbprint in joinInfoKey.GetSubKeyNames())
{
using (var entry = joinInfoKey.OpenSubKey(thumbprint))
{
if (entry == null) continue;
var tenantId = entry.GetValue("TenantId")?.ToString();
diag.JoinInfoEntries.Add(new JoinInfoSnapshot
{
Thumbprint = thumbprint,
HasTenantId = !string.IsNullOrEmpty(tenantId),
});
if (!string.IsNullOrEmpty(tenantId))
return tenantId;
}
}
}
}
catch (Exception ex)
{
diag.JoinInfoProbeError = ex.GetType().Name + ": " + ex.Message;
}
return null;
}
private static void LogResolutionMiss(AgentLogger logger, ResolverDiagnostics diag)
{
if (logger == null) return;
logger.Warning("TenantIdResolver: no TenantId resolvable from registry — device likely not yet AAD-joined / MDM-enrolled, or enrollment write hasn't completed.");
// Enrollments root.
if (diag.EnrollmentsProbeError != null)
logger.Warning($"TenantIdResolver: Enrollments probe failed: {diag.EnrollmentsProbeError}");
else if (diag.EnrollmentsRootMissing)
logger.Warning($"TenantIdResolver: HKLM\\{EnrollmentsKeyPath} not found.");
else if (diag.EnrollmentsSeen.Count == 0)
logger.Warning($"TenantIdResolver: HKLM\\{EnrollmentsKeyPath} present but empty.");
else
{
logger.Warning($"TenantIdResolver: HKLM\\{EnrollmentsKeyPath} carries {diag.EnrollmentsSeen.Count} enrollment(s) but none was Intune MDM (Type=6) with AADTenantID:");
foreach (var e in diag.EnrollmentsSeen)
{
logger.Warning(
$" - {e.Guid}: EnrollmentType={(e.EnrollmentType?.ToString() ?? "<missing>")}, " +
$"EnrollmentState={e.EnrollmentState ?? "<missing>"}, " +
$"AADTenantID={(e.HasAadTenantId ? "<present>" : "<missing>")}");
}
}
// CloudDomainJoin\TenantInfo.
if (diag.TenantInfoProbeError != null)
logger.Warning($"TenantIdResolver: TenantInfo probe failed: {diag.TenantInfoProbeError}");
else if (diag.TenantInfoRootMissing)
logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinTenantInfoPath} not found (device not AAD-joined yet).");
else if (diag.TenantInfoSubKeys.Count == 0)
logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinTenantInfoPath} present but no sub-keys.");
else
logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinTenantInfoPath} sub-keys: [{string.Join(", ", diag.TenantInfoSubKeys)}] — none parsed as a GUID.");
// CloudDomainJoin\JoinInfo.
if (diag.JoinInfoProbeError != null)
logger.Warning($"TenantIdResolver: JoinInfo probe failed: {diag.JoinInfoProbeError}");
else if (diag.JoinInfoRootMissing)
logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinJoinInfoPath} not found.");
else if (diag.JoinInfoEntries.Count == 0)
logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinJoinInfoPath} present but no thumbprint sub-keys.");
else
{
logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinJoinInfoPath} carries {diag.JoinInfoEntries.Count} entry/entries but none had a non-empty TenantId value:");
foreach (var j in diag.JoinInfoEntries)
logger.Warning($" - thumbprint={j.Thumbprint}, TenantId={(j.HasTenantId ? "<present>" : "<missing>")}");
}
}
private sealed class ResolverDiagnostics
{
public bool EnrollmentsRootMissing;
public string EnrollmentsProbeError;
public readonly List<EnrollmentSnapshot> EnrollmentsSeen = new List<EnrollmentSnapshot>();
public bool TenantInfoRootMissing;
public string TenantInfoProbeError;
public readonly List<string> TenantInfoSubKeys = new List<string>();
public bool JoinInfoRootMissing;
public string JoinInfoProbeError;
public readonly List<JoinInfoSnapshot> JoinInfoEntries = new List<JoinInfoSnapshot>();
}
private struct EnrollmentSnapshot
{
public string Guid;
public int? EnrollmentType;
public string EnrollmentState;
public bool HasAadTenantId;
}
private struct JoinInfoSnapshot
{
public string Thumbprint;
public bool HasTenantId;
}
}
}
@@ -21,8 +21,8 @@ namespace AutopilotMonitor.Agent.V2
/// <para>
/// Fetches remote config, executes all startup gather rules against a freshly-registered
/// ephemeral session, uploads the collected events, and exits. Parity with Legacy
/// <c>Program.GatherRulesMode.cs</c> — the V2 differences are minimal (V2 TenantIdResolver
/// instead of GetTenantIdFromRegistry, V2 BackendApiClient ctor).
/// <c>Program.GatherRulesMode.cs</c> — the V2 differences are minimal (V2 BackendApiClient
/// ctor; both rails now share the same TenantIdResolver shape).
/// </para>
/// </summary>
public static partial class Program
@@ -1 +1 @@
504
506
@@ -5,6 +5,7 @@ using System.Reflection;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.Core.Security;
using AutopilotMonitor.Shared;
using Microsoft.Win32;
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
@@ -17,39 +18,6 @@ namespace AutopilotMonitor.Agent
{
partial class Program
{
static string GetTenantIdFromRegistry()
{
try
{
const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
{
if (enrollmentsKey == null)
return null;
foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames())
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid))
{
if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
if (enrollmentType != null && Convert.ToInt32(enrollmentType) == 6)
{
var tenantId = enrollmentKey.GetValue("AADTenantID");
if (tenantId != null)
return tenantId.ToString();
}
}
}
}
}
catch { }
return null;
}
static AgentConfiguration LoadConfiguration(string[] args = null)
{
var noCleanup = args?.Contains("--no-cleanup") ?? false;
@@ -119,8 +87,10 @@ namespace AutopilotMonitor.Agent
selfDestructOnComplete = false;
}
// Determine TenantId from registry (or bootstrap-config.json fallback below)
string tenantId = GetTenantIdFromRegistry();
// Determine TenantId from registry (or bootstrap-config.json fallback below).
// No logger available at config-load time — the bail-site below re-probes with
// a logger so the diagnostic block lands in the agent log on a real miss.
string tenantId = TenantIdResolver.Resolve(logger: null);
var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
var sessionPersist = new SessionPersistence(dataDirectory);
+17 -7
View File
@@ -220,20 +220,20 @@ namespace AutopilotMonitor.Agent
if (consoleMode)
Console.WriteLine($"MDM certificate found: {cert.Thumbprint}");
// Re-read TenantId from registry — enrollment creates the registry key
// alongside the certificate, so it should be available now.
// Re-read TenantId — enrollment creates the registry key alongside the
// certificate, so it should be available now. TenantIdResolver also probes
// CloudDomainJoin as a fallback and emits a source-tagged log line on hit.
if (string.IsNullOrEmpty(config.TenantId))
{
config.TenantId = GetTenantIdFromRegistry();
config.TenantId = TenantIdResolver.Resolve(logger);
if (!string.IsNullOrEmpty(config.TenantId))
{
logger.Info($"Await-enrollment: TenantId discovered from registry: {config.TenantId}");
if (consoleMode)
Console.WriteLine($"Tenant ID: {config.TenantId}");
}
else
{
logger.Warning("Await-enrollment: MDM certificate found but TenantId not yet in registry");
logger.Warning("Await-enrollment: MDM certificate found but TenantId still not resolvable.");
}
}
@@ -257,8 +257,18 @@ namespace AutopilotMonitor.Agent
// Pre-flight: bail cleanly if TenantId is missing instead of crashing inside
// MonitoringService.IsValid(). Mirrors V2's AgentBootstrap guard. Common cause:
// agent scheduled before MDM enrollment populated HKLM\SOFTWARE\Microsoft\Enrollments
// and no bootstrap-config.json present. The Scheduled Task will retry on next trigger.
// agent scheduled before MDM enrollment / AAD join populated the registry and
// no bootstrap-config.json present. The Scheduled Task will retry on next trigger.
//
// Re-probe with the logger if we never went through the await-enrollment branch
// (LoadConfiguration ran without a logger, so its initial probe was silent). The
// re-probe either resolves the race (registry caught up between config load and
// here) or emits the diagnostic block so the agent log captures registry state.
if (string.IsNullOrEmpty(config.TenantId) && !config.AwaitEnrollment)
{
config.TenantId = TenantIdResolver.Resolve(logger);
}
if (string.IsNullOrEmpty(config.TenantId))
{
logger.Error("Agent cannot start: TenantId not available (registry empty + no bootstrap config). " +
@@ -1 +1 @@
1227
1228