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>>; public class ActiveDirectoryManagedGroups : IDisposable { private readonly ConcurrentDictionary managedGroups; private readonly Subject actionBuffer; private readonly IDisposable actionBufferSubscription; internal ActiveDirectoryManagedGroups() { managedGroups = new ConcurrentDictionary(); actionBuffer = new Subject(); // 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) { if (managedGroups.TryRemove(Key, out var item)) { item.Dispose(); return true; } return false; } public bool TryGetValue(string Key, out ADManagedGroup ManagedGroup) { return managedGroups.TryGetValue(Key, out ManagedGroup); } public bool TryGetValue(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 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("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 Actions) { ScheduledActionItemGrouping groupedActionItems; using (DiscoDataContext Database = new DiscoDataContext()) { groupedActionItems = Actions .GroupBy(a => a.ManagedGroup) .Where(g => { if (managedGroups.TryGetValue(g.Key.Key, out var 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(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("sAMAccountName")}"; accountDNCache[id] = scopeAccount.Value("distinguishedName"); } } foreach (var actionGroup in ActionGroups) { // Resolve Member Ids to AD Distinguished Names // Discard non-existent users var actionItems = actionGroup.Item2.Select(a => { if (!accountDNCache.TryGetValue(a.MemberId, out var distinguishedName)) { if (!ActiveDirectory.IsValidDomainAccountId(a.MemberId, out var memberUsername, out var 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("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("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("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 ManagedGroups, IScheduledTaskStatus Status) { List 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>(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("sAMAccountName")}"; accountDNCache[id] = Tuple.Create(scopeAccount.Value("distinguishedName"), scopeAccount.Value("displayName") ?? scopeAccount.Value("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 => { if (!accountDNCache.TryGetValue(a.MemberId, out var definition)) { if (!ActiveDirectory.IsValidDomainAccountId(a.MemberId, out var memberUsername, out var 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("distinguishedName"), adSearchResult.Value("displayName") ?? adSearchResult.Value("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("isCriticalSystemObject")) throw new InvalidOperationException($"This group [{adGroup.DistinguishedName}] is a Critical System Active Directory Object and Disco ICT refuses to modify it"); // Update Description if (actionGroup.Item1.Configuration.UpdateDescription) { var groupDescription = $"Disco ICT: {actionGroup.Item1.GroupDescription}"; if (adGroupEntry.Entry.Properties.Value("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() .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()) .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 readonly Func> 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> MemberResolver) { this.ManagedGroup = ManagedGroup; this.ActionType = ActionType; this.InvokingIdentifier = InvokingIdentifier; memberResolver = MemberResolver; } public IEnumerable ResolveMembers(DiscoDataContext Database) { if (memberResolver != null) { var members = memberResolver(Database); if (members == null) return Enumerable.Empty(); 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 } }