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);
}