Files
Disco/Disco.Services/Interop/ActiveDirectory/ActiveDirectoryManagedGroups.cs
T
2025-07-20 13:47:56 +10:00

497 lines
23 KiB
C#

using Disco.Data.Repository;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Subjects;
namespace Disco.Services.Interop.ActiveDirectory
{
using Disco.Services.Logging;
using Disco.Services.Tasks;
using ScheduledActionItemGrouping = List<Tuple<ADManagedGroup, List<ADManagedGroupScheduledActionItem>>>;
public class ActiveDirectoryManagedGroups : IDisposable
{
private ConcurrentDictionary<string, ADManagedGroup> managedGroups;
private Subject<ADManagedGroupScheduledAction> actionBuffer;
private IDisposable actionBufferSubscription;
internal ActiveDirectoryManagedGroups()
{
managedGroups = new ConcurrentDictionary<string, ADManagedGroup>();
actionBuffer = new Subject<ADManagedGroupScheduledAction>();
// Subscribe, wait for no additional actions after 10 seconds
actionBufferSubscription = actionBuffer
.BufferWithInactivity(TimeSpan.FromSeconds(10))
.Subscribe(ParseScheduledActions);
}
#region Collection Methods
public void AddOrUpdate(ADManagedGroup ManagedGroup)
{
ManagedGroup.Context = this;
ManagedGroup.Initialize();
string key = ManagedGroup.Key;
var existingGroup = managedGroups.Values
.Where(g => g.Key != ManagedGroup.Key)
.FirstOrDefault(g => g.Configuration.GroupId.Equals(ManagedGroup.Configuration.GroupId, StringComparison.OrdinalIgnoreCase));
if (existingGroup != null)
throw new ArgumentException($"[{ManagedGroup.Key}] cannot manage this group [{ManagedGroup.Configuration.GroupId}] because is already managed by [{existingGroup.Key}]", "ManagedGroup");
managedGroups.AddOrUpdate(key, ManagedGroup, (itemKey, item) =>
{
item.Dispose();
return ManagedGroup;
});
}
public bool Remove(string Key)
{
ADManagedGroup item;
if (managedGroups.TryRemove(Key, out item))
{
item.Dispose();
return true;
}
return false;
}
public bool TryGetValue(string Key, out ADManagedGroup ManagedGroup)
{
return managedGroups.TryGetValue(Key, out ManagedGroup);
}
public bool TryGetValue<T>(string key, out T managedGroup) where T : ADManagedGroup
{
if (managedGroups.TryGetValue(key, out var item) && item is T typedItem)
{
managedGroup = typedItem;
return true;
}
else
{
managedGroup = null;
return false;
}
}
public List<ADManagedGroup> Values
{
get
{
return managedGroups.Values.ToList();
}
}
#endregion
public string ValidateGroupId(string GroupId, string IgnoreManagedGroupKey)
{
var group = ActiveDirectory.RetrieveADGroup(GroupId, "isCriticalSystemObject");
if (group == null)
throw new ArgumentException($"The group [{GroupId}] wasn't found", "DevicesLinkedGroup");
if (group.GetPropertyValue<bool>("isCriticalSystemObject"))
throw new ArgumentException($"The group [{group.DistinguishedName}] is a Critical System Active Directory Object and Disco ICT refuses to modify it", "DevicesLinkedGroup");
GroupId = group.Id;
var otherManagedGroup = ActiveDirectory.Context.ManagedGroups.Values
.Where(g => g.Key != IgnoreManagedGroupKey)
.FirstOrDefault(g => g.Configuration.GroupId.Equals(GroupId, StringComparison.OrdinalIgnoreCase));
if (otherManagedGroup != null)
throw new ArgumentException($"Cannot manage this group [{GroupId}] because is already managed by [{otherManagedGroup.Key}]", "DevicesLinkedGroup");
return GroupId;
}
internal void ScheduleAction(ADManagedGroupScheduledAction ScheduledAction)
{
actionBuffer.OnNext(ScheduledAction);
}
private void ParseScheduledActions(IEnumerable<ADManagedGroupScheduledAction> Actions)
{
ScheduledActionItemGrouping groupedActionItems;
using (DiscoDataContext Database = new DiscoDataContext())
{
groupedActionItems = Actions
.GroupBy(a => a.ManagedGroup)
.Where(g =>
{
ADManagedGroup item;
if (managedGroups.TryGetValue(g.Key.Key, out item))
return item == g.Key;
else
return false;
})
.Select(g => // Reduce actions to last instance of ActionSubjectId
Tuple.Create(
g.Key,
g.GroupBy(i => i.InvokingIdentifier, (id, idg) => idg.Last())
)
).Select(g => // Resolve action group members (action subjects)
Tuple.Create(g.Item1, g.Item2.SelectMany(i => i.ResolveMembers(Database)))
).Select(g => // Reduce actions to last instance of MemberId
Tuple.Create(
g.Item1,
g.Item2.GroupBy(i => i.MemberId, (id, idg) => idg.Last()).ToList()
)
).ToList();
}
ApplyScheduledActionItems(groupedActionItems);
}
private void ApplyScheduledActionItems(ScheduledActionItemGrouping ActionGroups)
{
var actionsCount = ActionGroups.SelectMany(a => a.Item2).Count();
if (actionsCount > 0)
{
var adSearchLoadProperties = new string[] { "distinguishedName", "sAMAccountName" };
var accountDNCache = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (actionsCount > 40)
{
// Potentially over 40 accounts, cache all scoped
var scopeAccounts = ActiveDirectory.Context.SearchScope("(|(objectCategory=computer)(objectCategory=person))", adSearchLoadProperties);
foreach (var scopeAccount in scopeAccounts)
{
var id = $@"{scopeAccount.Domain.NetBiosName}\{scopeAccount.Value<string>("sAMAccountName")}";
accountDNCache[id] = scopeAccount.Value<string>("distinguishedName");
}
}
foreach (var actionGroup in ActionGroups)
{
// Resolve Member Ids to AD Distinguished Names
// Discard non-existent users
var actionItems = actionGroup.Item2.Select(a =>
{
string distinguishedName;
if (!accountDNCache.TryGetValue(a.MemberId, out distinguishedName))
{
string memberUsername;
ADDomain memberDomain;
if (!ActiveDirectory.IsValidDomainAccountId(a.MemberId, out memberUsername, out memberDomain))
{
accountDNCache[a.MemberId] = null; // Add to cache (avoid retries)
return null;
}
var ldapFilter = $"(&(|(objectCategory=computer)(objectCategory=person))(sAMAccountName={memberUsername}))";
var adSearchResult = memberDomain.SearchEntireDomain(ldapFilter, adSearchLoadProperties, ActiveDirectory.SingleSearchResult).FirstOrDefault();
if (adSearchResult != null)
{
var adSearchResultDN = adSearchResult.Value<string>("distinguishedName");
accountDNCache[a.MemberId] = adSearchResultDN; // Add to cache
a.MemberDistinguishedName = adSearchResultDN; // Update ActionItem
return a;
}
else
{
accountDNCache[a.MemberId] = null; // Add to cache (avoid retries)
return null;
}
}
else if (distinguishedName == null)
return null;
else
{
a.MemberDistinguishedName = distinguishedName; // Update ActionItem
return a;
}
}).Where(a => a != null).ToList();
if (actionItems.Count > 0)
{
var adGroup = actionGroup.Item1.GetGroup();
if (adGroup == null)
{
SystemLog.LogWarning("Active Directory Managed Group", actionGroup.Item1.Key, "Group Not Found", actionGroup.Item1.Configuration.GroupId);
break;
}
var adGroupMembers = adGroup.GetPropertyValues<string>("member").ToList();
actionItems = actionItems.Where(a =>
{
switch (a.ActionType)
{
case ADManagedGroupScheduledActionType.AddGroupMember:
return !adGroupMembers.Contains(a.MemberDistinguishedName);
case ADManagedGroupScheduledActionType.RemoveGroupMember:
return adGroupMembers.Contains(a.MemberDistinguishedName);
default:
return false;
}
}).ToList();
if (actionItems.Count > 0)
{
using (var adGroupEntry = ActiveDirectory.Context.RetrieveDirectoryEntry(adGroup.DistinguishedName, new string[] { "member", "isCriticalSystemObject" }))
{
if (adGroupEntry.Entry.Properties.Value<bool>("isCriticalSystemObject"))
throw new InvalidOperationException($"This group [{adGroup.DistinguishedName}] is a Critical System Active Directory Object and Disco ICT refuses to modify it");
var adGroupEntryMembers = adGroupEntry.Entry.Properties["member"];
foreach (var item in actionItems)
{
switch (item.ActionType)
{
case ADManagedGroupScheduledActionType.AddGroupMember:
if (!adGroupEntryMembers.Contains(item.MemberDistinguishedName))
{
// Add Member Entry
adGroupEntryMembers.Add(item.MemberDistinguishedName);
}
break;
case ADManagedGroupScheduledActionType.RemoveGroupMember:
if (adGroupEntryMembers.Contains(item.MemberDistinguishedName))
{
// Add Member Entry
adGroupEntryMembers.Remove(item.MemberDistinguishedName);
}
break;
}
}
// Commit Changes
adGroupEntry.Entry.CommitChanges();
}
}
}
}
}
}
public int SyncManagedGroups(IScheduledTaskStatus Status)
{
return SyncManagedGroups(managedGroups.Values, Status);
}
public int SyncManagedGroups(ADManagedGroup ManagedGroup, IScheduledTaskStatus Status)
{
return SyncManagedGroups(new ADManagedGroup[] { ManagedGroup }, Status);
}
public int SyncManagedGroups(IEnumerable<ADManagedGroup> ManagedGroups, IScheduledTaskStatus Status)
{
List<ADManagedGroup> managedGroups = ManagedGroups.ToList();
ScheduledActionItemGrouping actionGroups;
int changeCount = 0;
Status.UpdateStatus(0, "Determining Managed Group Members");
using (DiscoDataContext Database = new DiscoDataContext())
{
actionGroups = managedGroups.Select((g, index) =>
{
Status.UpdateStatus(
((double)30 / managedGroups.Count) * index, // 0 -> 30
$"Determining Group Members: {g.GroupDescription} [{g.Configuration.GroupId}]");
return Tuple.Create(
g,
g.DetermineMembers(Database).Select(m =>
new ADManagedGroupScheduledActionItem(
g,
ADManagedGroupScheduledActionType.AddGroupMember,
m
)).ToList());
}).ToList();
}
var actionsCount = actionGroups.SelectMany(a => a.Item2).Count();
if (actionsCount > 0)
{
Status.UpdateStatus(30, "Resolving Group Members");
var adSearchLoadProperties = new string[] { "distinguishedName", "sAMAccountName", "displayName", "name" };
var accountDNCache = new Dictionary<string, Tuple<string, string>>(StringComparer.OrdinalIgnoreCase);
if (actionsCount > 40)
{
// Potentially over 40 accounts, cache all scoped
var scopeAccounts = ActiveDirectory.Context.SearchScope("(|(objectCategory=computer)(objectCategory=person))", adSearchLoadProperties);
foreach (var scopeAccount in scopeAccounts)
{
var id = $@"{scopeAccount.Domain.NetBiosName}\{scopeAccount.Value<string>("sAMAccountName")}";
accountDNCache[id] = Tuple.Create(scopeAccount.Value<string>("distinguishedName"), scopeAccount.Value<string>("displayName") ?? scopeAccount.Value<string>("name"));
}
}
actionGroups = actionGroups.Select((g, index) =>
{
Status.UpdateStatus(
30 + (((double)30 / actionGroups.Count) * index), // 30 -> 60
$"Resolving {g.Item2.Count} Group Members: {g.Item1.GroupDescription} [{g.Item1.Configuration.GroupId}]");
// Resolve Member Ids to AD Distinguished Names
// Discard non-existent users
return Tuple.Create(
g.Item1,
g.Item2.Select(a =>
{
Tuple<string, string> definition;
if (!accountDNCache.TryGetValue(a.MemberId, out definition))
{
string memberUsername;
ADDomain memberDomain;
if (!ActiveDirectory.IsValidDomainAccountId(a.MemberId, out memberUsername, out memberDomain))
{
accountDNCache[a.MemberId] = null; // Add to cache (avoid retries)
return null;
}
var ldapFilter = $"(&(|(objectCategory=computer)(objectCategory=person))(sAMAccountName={memberUsername}))";
var adSearchResult = memberDomain.SearchEntireDomain(ldapFilter, adSearchLoadProperties, ActiveDirectory.SingleSearchResult).FirstOrDefault();
if (adSearchResult != null)
{
definition = Tuple.Create(adSearchResult.Value<string>("distinguishedName"), adSearchResult.Value<string>("displayName") ?? adSearchResult.Value<string>("name"));
accountDNCache[a.MemberId] = definition; // Add to cache
}
else
{
accountDNCache[a.MemberId] = null; // Add to cache (avoid retries)
return null;
}
}
else if (definition == null)
return null;
a.MemberDistinguishedName = definition.Item1; // Update ActionItem
a.MemberDisplayName = definition.Item2;
return a;
}).Where(a => a != null).ToList());
}).ToList();
}
foreach (var actionGroup in actionGroups)
{
var adGroup = actionGroup.Item1.GetGroup();
if (adGroup == null)
{
SystemLog.LogWarning("Active Directory Managed Group", actionGroup.Item1.Key, "Group Not Found", actionGroup.Item1.Configuration.GroupId);
break;
}
Status.UpdateStatus(
60 + (((double)40 / actionGroups.Count) * actionGroups.IndexOf(actionGroup)), // 60 -> 100
$"Synchronizing {actionGroup.Item2.Count} Group Members: {actionGroup.Item1.GroupDescription} [{actionGroup.Item1.Configuration.GroupId}]");
using (var adGroupEntry = ActiveDirectory.Context.RetrieveDirectoryEntry(adGroup.DistinguishedName, new string[] { "isCriticalSystemObject", "description", "member" }))
{
if (adGroupEntry.Entry.Properties.Value<bool>("isCriticalSystemObject"))
throw new InvalidOperationException($"This group [{adGroup.DistinguishedName}] is a Critical System Active Directory Object and Disco ICT refuses to modify it");
// Update Description
var groupDescription = $"Disco ICT: {actionGroup.Item1.GroupDescription}";
if (adGroupEntry.Entry.Properties.Value<string>("description") != groupDescription)
{
var adGroupEntryDescription = adGroupEntry.Entry.Properties["description"];
if (adGroupEntryDescription.Count > 0)
adGroupEntryDescription.Clear();
adGroupEntryDescription.Add(groupDescription);
}
// Sync Members
var adGroupEntryMembers = adGroupEntry.Entry.Properties["member"];
// Remove Items
var removeItems = adGroupEntryMembers
.Cast<string>()
.Except(actionGroup.Item2.Select(i => i.MemberDistinguishedName))
.ToList();
removeItems.ForEach(i => adGroupEntryMembers.Remove(i));
// Add Items
var addItems = actionGroup.
Item2.Select(i => i.MemberDistinguishedName)
.Except(adGroupEntryMembers.Cast<string>())
.ToList();
addItems.ForEach(i => adGroupEntryMembers.Add(i));
// Commit Changes
adGroupEntry.Entry.CommitChanges();
changeCount += removeItems.Count;
changeCount += addItems.Count;
}
}
Status.UpdateStatus(100, "Managed Group Synchronization Finished");
return changeCount;
}
public void Dispose()
{
if (actionBufferSubscription != null)
actionBufferSubscription.Dispose();
if (actionBuffer != null)
actionBuffer.Dispose();
}
}
internal class ADManagedGroupScheduledAction
{
private Func<DiscoDataContext, IEnumerable<string>> memberResolver;
public ADManagedGroup ManagedGroup { get; private set; }
public ADManagedGroupScheduledActionType ActionType { get; private set; }
public string InvokingIdentifier { get; set; }
public ADManagedGroupScheduledAction(ADManagedGroup ManagedGroup, ADManagedGroupScheduledActionType ActionType, string InvokingIdentifier, Func<DiscoDataContext, IEnumerable<string>> MemberResolver)
{
this.ManagedGroup = ManagedGroup;
this.ActionType = ActionType;
this.InvokingIdentifier = InvokingIdentifier;
memberResolver = MemberResolver;
}
public IEnumerable<ADManagedGroupScheduledActionItem> ResolveMembers(DiscoDataContext Database)
{
if (memberResolver != null)
{
var members = memberResolver(Database);
if (members == null)
return Enumerable.Empty<ADManagedGroupScheduledActionItem>();
else
return members.Select(m =>
new ADManagedGroupScheduledActionItem(ManagedGroup, ActionType, m)
);
}
else
{
return new ADManagedGroupScheduledActionItem[]
{
new ADManagedGroupScheduledActionItem(ManagedGroup, ActionType, InvokingIdentifier)
};
}
}
}
internal class ADManagedGroupScheduledActionItem
{
public ADManagedGroup ManagedGroup { get; private set; }
public ADManagedGroupScheduledActionType ActionType { get; private set; }
public string MemberId { get; set; }
public string MemberDistinguishedName { get; set; }
public string MemberDisplayName { get; set; }
public ADManagedGroupScheduledActionItem(ADManagedGroup ManagedGroup, ADManagedGroupScheduledActionType ActionType, string MemberId)
{
this.ManagedGroup = ManagedGroup;
this.ActionType = ActionType;
this.MemberId = MemberId;
}
}
internal enum ADManagedGroupScheduledActionType
{
AddGroupMember,
RemoveGroupMember
}
}