feat(agent): V2 TenantIdResolver falls back to CloudDomainJoin registry

Resolver now probes Enrollments → CloudDomainJoin\TenantInfo →
CloudDomainJoin\JoinInfo and tags the winning source in the log. On a
full miss it dumps a diagnostic block (which roots exist, which
enrollment GUIDs were seen with their EnrollmentType/EnrollmentState,
which JoinInfo thumbprints). Heals the OOBE race where the agent fires
before MDM enrollment is written but AAD join already populated
CloudDomainJoin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-30 18:59:33 +02:00
parent 86ae616b3b
commit fc95075735
3 changed files with 237 additions and 31 deletions
@@ -1,30 +1,85 @@
using System; using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Logging;
using Microsoft.Win32; using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Security namespace AutopilotMonitor.Agent.V2.Core.Security
{ {
/// <summary> /// <summary>
/// Resolves the Autopilot device's AAD TenantId from the MDM enrollment registry. /// Resolves the device's AAD TenantId from one of several Windows registry signals.
/// Plan §4.x M4.5.b. /// Plan §4.x M4.5.b.
/// <para> /// <para>
/// The MDM enrollment writes one sub-key per enrollment under /// Probe order, first non-empty hit wins:
/// <c>HKLM\SOFTWARE\Microsoft\Enrollments</c>. The entry with <c>EnrollmentType==6</c> /// </para>
/// (MDM Intune enrollment) carries <c>AADTenantID</c> — the only value that maps the /// <list type="number">
/// device back to its Entra ID tenant without a backend round-trip. /// <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> /// </para>
/// </summary> /// </summary>
public static class TenantIdResolver public static class TenantIdResolver
{ {
private const string EnrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; 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; 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> /// <summary>
/// Walks the enrollments subtree and returns the first <c>AADTenantID</c> tagged with /// Tries each probe in order and returns the first non-empty TenantId.
/// the Intune MDM enrollment type. Returns <c>null</c> when the device is not MDM-enrolled /// Logs the winning source at Info level. On full miss, logs a diagnostic
/// or the registry key is inaccessible. Never throws. /// summary at Warning level. Returns <c>null</c> when nothing resolves.
/// </summary> /// </summary>
public static string ResolveFromEnrollmentRegistry(AgentLogger logger = null) 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 try
{ {
@@ -32,7 +87,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
{ {
if (enrollmentsKey == null) if (enrollmentsKey == null)
{ {
logger?.Warning($"TenantIdResolver: {EnrollmentsKeyPath} not found — device likely not MDM-enrolled."); diag.EnrollmentsRootMissing = true;
return null; return null;
} }
@@ -43,33 +98,184 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
if (enrollmentKey == null) continue; if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
if (enrollmentType == null) continue; int? typeValue = null;
if (enrollmentType != null)
int typeValue;
try { typeValue = Convert.ToInt32(enrollmentType); }
catch { continue; }
if (typeValue != IntuneMdmEnrollmentType) continue;
var tenantId = enrollmentKey.GetValue("AADTenantID");
var resolved = tenantId?.ToString();
if (!string.IsNullOrEmpty(resolved))
{ {
logger?.Debug($"TenantIdResolver: resolved AADTenantID={resolved} from enrollment {enrollmentGuid}."); try { typeValue = Convert.ToInt32(enrollmentType); }
return resolved; 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;
} }
} }
} }
logger?.Warning("TenantIdResolver: no Intune MDM enrollment (EnrollmentType=6) with AADTenantID found.");
return null;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger?.Error("TenantIdResolver: registry probe failed.", ex); diag.EnrollmentsProbeError = ex.GetType().Name + ": " + ex.Message;
return null;
} }
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;
} }
} }
} }
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.V2
try try
{ {
var tenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger); var tenantId = TenantIdResolver.Resolve(logger);
if (string.IsNullOrEmpty(tenantId)) if (string.IsNullOrEmpty(tenantId))
{ {
logger.Error("--run-gather-rules: TenantId could not be resolved — device is not MDM-enrolled."); logger.Error("--run-gather-rules: TenantId could not be resolved — device is not MDM-enrolled.");
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
var bootstrapConfig = Program.TryReadBootstrapConfig(dataDirectory, logger); var bootstrapConfig = Program.TryReadBootstrapConfig(dataDirectory, logger);
var awaitConfig = Program.TryReadAwaitEnrollmentConfig(dataDirectory, logger); var awaitConfig = Program.TryReadAwaitEnrollmentConfig(dataDirectory, logger);
var tenantIdFromRegistry = TenantIdResolver.ResolveFromEnrollmentRegistry(logger); var tenantIdFromRegistry = TenantIdResolver.Resolve(logger);
var tenantId = !string.IsNullOrEmpty(tenantIdFromRegistry) var tenantId = !string.IsNullOrEmpty(tenantIdFromRegistry)
? tenantIdFromRegistry ? tenantIdFromRegistry
: bootstrapConfig?.TenantId; : bootstrapConfig?.TenantId;
@@ -111,7 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// Re-resolve TenantId — enrollment typically writes the registry key alongside the cert. // Re-resolve TenantId — enrollment typically writes the registry key alongside the cert.
if (string.IsNullOrEmpty(agentConfig.TenantId)) if (string.IsNullOrEmpty(agentConfig.TenantId))
{ {
agentConfig.TenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger); agentConfig.TenantId = TenantIdResolver.Resolve(logger);
if (!string.IsNullOrEmpty(agentConfig.TenantId)) if (!string.IsNullOrEmpty(agentConfig.TenantId))
logger.Info($"Await-enrollment: TenantId discovered from registry: {agentConfig.TenantId}"); logger.Info($"Await-enrollment: TenantId discovered from registry: {agentConfig.TenantId}");
} }