using System.Collections.Concurrent; using System.DirectoryServices.AccountManagement; using System.Runtime.Versioning; using System.Security.Principal; using SQLInsight.Core.Data; namespace SQLInsight.Agent.Security; /// /// Unfolds a Windows group (DOMAIN\Group) to the people in it, recursively, by asking Active Directory as the agent's /// account (the computer account for the default virtual account, which may read the directory in a domain). /// Answers are cached for an hour (failures for ten minutes) and each lookup is cut off after 20 seconds, so a slow or /// unreachable domain controller never holds up the monitoring. /// [SupportedOSPlatform("windows")] public sealed partial class ActiveDirectoryGroupResolver(TimeProvider time, ILogger logger) : IGroupMemberResolver { public static readonly TimeSpan CacheFor = TimeSpan.FromHours(1); public static readonly TimeSpan CacheFailuresFor = TimeSpan.FromMinutes(10); public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(20); private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); public async Task ExpandAsync(string group, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(group); var now = time.GetUtcNow(); if (_cache.TryGetValue(group, out var cached) && now - cached.At < (cached.Result.Error is null ? CacheFor : CacheFailuresFor)) { return cached.Result; } GroupExpansion result; try { result = await Task.Run(() => Expand(group), cancellationToken).WaitAsync(Timeout, cancellationToken).ConfigureAwait(false); } catch (TimeoutException) { result = GroupExpansion.Failed($"Active Directory svarede ikke inden for {Timeout.TotalSeconds:0} sekunder."); } catch (Exception ex) when (ex is PrincipalException or System.DirectoryServices.DirectoryServicesCOMException or System.Runtime.InteropServices.COMException or InvalidOperationException or UnauthorizedAccessException or ArgumentException) { result = GroupExpansion.Failed($"Active Directory kunne ikke spørges: {ex.Message}"); } if (result.Error is not null) { LogExpansionFailed(logger, group, result.Error); } _cache[group] = (now, result); return result; } private static GroupExpansion Expand(string group) { var separator = group.IndexOf('\\', StringComparison.Ordinal); if (separator <= 0) { return GroupExpansion.Failed("Gruppen har intet domæne (forventet DOMÆNE\\gruppe)."); } var domain = group[..separator]; var name = group[(separator + 1)..]; if (domain.Equals("BUILTIN", StringComparison.OrdinalIgnoreCase) || domain.Equals("NT AUTHORITY", StringComparison.OrdinalIgnoreCase)) { return GroupExpansion.Failed("En lokal gruppe på SQL Server-maskinen; medlemmerne kan ikke læses fra Active Directory."); } var local = domain.Equals(Environment.MachineName, StringComparison.OrdinalIgnoreCase); using var context = local ? new PrincipalContext(ContextType.Machine) : new PrincipalContext(ContextType.Domain, domain); using var principal = GroupPrincipal.FindByIdentity(context, IdentityType.SamAccountName, name); if (principal is null) { return GroupExpansion.Failed(local ? "Gruppen findes ikke på maskinen." : $"Gruppen findes ikke i domænet {domain}."); } var members = new List(); using var found = principal.GetMembers(recursive: true); foreach (var member in found) { using (member) { var account = Account(member, domain); var enabled = member is AuthenticablePrincipal authenticable ? authenticable.Enabled ?? true : true; members.Add(new GroupMember(account, member.DisplayName, enabled)); } } return new GroupExpansion(members.DistinctBy(m => m.Account, StringComparer.OrdinalIgnoreCase).OrderBy(m => m.Account, StringComparer.OrdinalIgnoreCase).ToList()); } /// DOMAIN\sam, from the SID so members of other domains get their own domain. private static string Account(Principal member, string fallbackDomain) { try { if (member.Sid?.Translate(typeof(NTAccount)) is NTAccount account) { return account.Value; } } catch (IdentityNotMappedException) { } return $@"{fallbackDomain}\{member.SamAccountName}"; } [LoggerMessage(Level = LogLevel.Warning, Message = "Windows group {Group} could not be unfolded: {Error}")] private static partial void LogExpansionFailed(ILogger logger, string group, string error); }