From fc9507573583e869bb9b4fe3f6244dd3b7e696d2 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 30 Apr 2026 18:59:33 +0200 Subject: [PATCH] feat(agent): V2 TenantIdResolver falls back to CloudDomainJoin registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Security/TenantIdResolver.cs | 262 ++++++++++++++++-- .../Program.GatherRulesMode.cs | 2 +- .../Runtime/AgentBootstrap.cs | 4 +- 3 files changed, 237 insertions(+), 31 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs index eb4b5198..6741eee7 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs @@ -1,30 +1,85 @@ using System; +using System.Collections.Generic; using AutopilotMonitor.Agent.V2.Core.Logging; using Microsoft.Win32; namespace AutopilotMonitor.Agent.V2.Core.Security { /// - /// 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. /// - /// The MDM enrollment writes one sub-key per enrollment under - /// HKLM\SOFTWARE\Microsoft\Enrollments. The entry with EnrollmentType==6 - /// (MDM Intune enrollment) carries AADTenantID — the only value that maps the - /// device back to its Entra ID tenant without a backend round-trip. + /// Probe order, first non-empty hit wins: + /// + /// + /// + /// HKLM\SOFTWARE\Microsoft\Enrollments\{guid} with EnrollmentType=6 + /// (Intune MDM) and AADTenantID. Authoritative once MDM enrollment finished. + /// + /// + /// HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo\{TenantId} + /// — the sub-key name itself is the TenantId GUID. Written by AAD join, which in + /// Autopilot/OOBE happens before MDM enrollment. + /// + /// + /// HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\{thumbprint} + /// value TenantId. Same AAD-join phase as TenantInfo, used as a secondary + /// reading because some flows populate one but not the other. + /// + /// + /// + /// Returns null 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". /// /// 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"; + /// - /// Walks the enrollments subtree and returns the first AADTenantID tagged with - /// the Intune MDM enrollment type. Returns null when the device is not MDM-enrolled - /// or the registry key is inaccessible. Never throws. + /// 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 null when nothing resolves. /// - 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 { @@ -32,7 +87,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security { if (enrollmentsKey == null) { - logger?.Warning($"TenantIdResolver: {EnrollmentsKeyPath} not found — device likely not MDM-enrolled."); + diag.EnrollmentsRootMissing = true; return null; } @@ -43,33 +98,184 @@ namespace AutopilotMonitor.Agent.V2.Core.Security if (enrollmentKey == null) continue; var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); - if (enrollmentType == null) continue; - - 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)) + int? typeValue = null; + if (enrollmentType != null) { - logger?.Debug($"TenantIdResolver: resolved AADTenantID={resolved} from enrollment {enrollmentGuid}."); - return resolved; + 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; } } } - - logger?.Warning("TenantIdResolver: no Intune MDM enrollment (EnrollmentType=6) with AADTenantID found."); - return null; } catch (Exception ex) { - logger?.Error("TenantIdResolver: registry probe failed.", ex); - return null; + 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() ?? "")}, " + + $"EnrollmentState={e.EnrollmentState ?? ""}, " + + $"AADTenantID={(e.HasAadTenantId ? "" : "")}"); + } + } + + // 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 ? "" : "")}"); + } + } + + private sealed class ResolverDiagnostics + { + public bool EnrollmentsRootMissing; + public string EnrollmentsProbeError; + public readonly List EnrollmentsSeen = new List(); + + public bool TenantInfoRootMissing; + public string TenantInfoProbeError; + public readonly List TenantInfoSubKeys = new List(); + + public bool JoinInfoRootMissing; + public string JoinInfoProbeError; + public readonly List JoinInfoEntries = new List(); + } + + private struct EnrollmentSnapshot + { + public string Guid; + public int? EnrollmentType; + public string EnrollmentState; + public bool HasAadTenantId; + } + + private struct JoinInfoSnapshot + { + public string Thumbprint; + public bool HasTenantId; } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs index 86749a03..6d51cf3d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs @@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.V2 try { - var tenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger); + var tenantId = TenantIdResolver.Resolve(logger); if (string.IsNullOrEmpty(tenantId)) { logger.Error("--run-gather-rules: TenantId could not be resolved — device is not MDM-enrolled."); diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentBootstrap.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentBootstrap.cs index 55bb4a89..7509e1d1 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentBootstrap.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentBootstrap.cs @@ -42,7 +42,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime var bootstrapConfig = Program.TryReadBootstrapConfig(dataDirectory, logger); var awaitConfig = Program.TryReadAwaitEnrollmentConfig(dataDirectory, logger); - var tenantIdFromRegistry = TenantIdResolver.ResolveFromEnrollmentRegistry(logger); + var tenantIdFromRegistry = TenantIdResolver.Resolve(logger); var tenantId = !string.IsNullOrEmpty(tenantIdFromRegistry) ? tenantIdFromRegistry : bootstrapConfig?.TenantId; @@ -111,7 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime // Re-resolve TenantId — enrollment typically writes the registry key alongside the cert. if (string.IsNullOrEmpty(agentConfig.TenantId)) { - agentConfig.TenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger); + agentConfig.TenantId = TenantIdResolver.Resolve(logger); if (!string.IsNullOrEmpty(agentConfig.TenantId)) logger.Info($"Await-enrollment: TenantId discovered from registry: {agentConfig.TenantId}"); }