Feature #49: Active Directory Managed Groups

Document Template Attachments, Device Batches, Device Profiles and User
Flags can be associated with an Active Directory group. This AD group is
then automatically synchronized with relevant User/Machine accounts.
Contains various other UI tweaks and configuration enhancements.
This commit is contained in:
Gary Sharp
2014-06-16 22:21:31 +10:00
parent ebf78dd08d
commit a819d2722a
119 changed files with 8349 additions and 2373 deletions
@@ -193,26 +193,34 @@ namespace Disco.Services.Interop.ActiveDirectory
#endregion
#region Groups
public ADGroup RetrieveADGroup(string Id)
public ADGroup RetrieveADGroup(string Id, string[] AdditionalProperties = null)
{
var result = RetrieveBySamAccountName(Id, ADGroup.LdapSamAccountNameFilterTemplate, ADGroup.LoadProperties);
string[] loadProperites = (AdditionalProperties != null && AdditionalProperties.Length > 0)
? ADGroup.LoadProperties.Concat(AdditionalProperties).ToArray()
: ADGroup.LoadProperties;
var result = RetrieveBySamAccountName(Id, ADGroup.LdapSamAccountNameFilterTemplate, loadProperites);
if (result == null)
return null;
else
return result.AsADGroup();
return result.AsADGroup(AdditionalProperties);
}
public ADGroup RetrieveADGroupByDistinguishedName(string DistinguishedName)
public ADGroup RetrieveADGroupByDistinguishedName(string DistinguishedName, string[] AdditionalProperties = null)
{
using (var groupEntry = this.RetrieveDirectoryEntry(DistinguishedName, ADGroup.LoadProperties))
string[] loadProperites = (AdditionalProperties != null && AdditionalProperties.Length > 0)
? ADGroup.LoadProperties.Concat(AdditionalProperties).ToArray()
: ADGroup.LoadProperties;
using (var groupEntry = this.RetrieveDirectoryEntry(DistinguishedName, loadProperites))
{
if (groupEntry == null)
return null;
return groupEntry.AsADGroup();
return groupEntry.AsADGroup(AdditionalProperties);
}
}
public ADGroup RetrieveADGroupWithSecurityIdentifier(SecurityIdentifier SecurityIdentifier)
public ADGroup RetrieveADGroupWithSecurityIdentifier(SecurityIdentifier SecurityIdentifier, string[] AdditionalProperties = null)
{
if (SecurityIdentifier == null)
throw new ArgumentNullException("SecurityIdentifier");
@@ -222,12 +230,15 @@ namespace Disco.Services.Interop.ActiveDirectory
var sidBinaryString = SecurityIdentifier.ToBinaryString();
string ldapFilter = string.Format(ADGroup.LdapSecurityIdentifierFilterTemplate, sidBinaryString);
string[] loadProperites = (AdditionalProperties != null && AdditionalProperties.Length > 0)
? ADGroup.LoadProperties.Concat(AdditionalProperties).ToArray()
: ADGroup.LoadProperties;
var result = this.SearchEntireDomain(ldapFilter, ADGroup.LoadProperties, ActiveDirectory.SingleSearchResult).FirstOrDefault();
var result = this.SearchEntireDomain(ldapFilter, loadProperites, ActiveDirectory.SingleSearchResult).FirstOrDefault();
if (result == null)
return null;
else
return result.AsADGroup();
return result.AsADGroup(AdditionalProperties);
}
#endregion
@@ -236,7 +247,7 @@ namespace Disco.Services.Interop.ActiveDirectory
private static readonly string[] ObjectLoadProperties = { "objectCategory" };
private static readonly string[] ObjectLoadPropertiesAll = ObjectLoadProperties.Concat(ADUserAccount.LoadProperties).Concat(ADMachineAccount.LoadProperties).Concat(ADGroup.LoadProperties).Distinct().ToArray();
public IADObject RetrieveADObject(string Id, bool Quick)
public IADObject RetrieveADObject(string Id, bool Quick, string[] AdditionalProperties = null)
{
var result = RetrieveBySamAccountName(Id, ObjectLdapSamAccountNameFilter, ObjectLoadPropertiesAll);
@@ -249,11 +260,11 @@ namespace Disco.Services.Interop.ActiveDirectory
switch (objectCategory)
{
case "cn=person":
return result.AsADUserAccount(Quick, null);
return result.AsADUserAccount(Quick, AdditionalProperties);
case "cn=computer":
return result.AsADMachineAccount(null);
return result.AsADMachineAccount(AdditionalProperties);
case "cn=group":
return result.AsADGroup();
return result.AsADGroup(AdditionalProperties);
default:
throw new InvalidOperationException("Unexpected objectCategory");
}
@@ -294,12 +305,12 @@ namespace Disco.Services.Interop.ActiveDirectory
private ADSearchResult RetrieveBySamAccountName(string Id, string LdapFilterTemplate, string[] LoadProperties)
{
var splitId = UserExtensions.SplitUserId(Id);
var slashIndex = Id.IndexOf('\\');
if (!this.Domain.NetBiosName.Equals(splitId.Item1, StringComparison.OrdinalIgnoreCase))
if (!this.Domain.NetBiosName.Equals(Id.Substring(0, slashIndex), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException(string.Format("The Id [{0}] is invalid for this domain [{1}]", Id, this.Domain.Name), "Id");
var ldapFilter = string.Format(LdapFilterTemplate, splitId.Item2);
var ldapFilter = string.Format(LdapFilterTemplate, Id.Substring(slashIndex + 1));
return this.SearchEntireDomain(ldapFilter, LoadProperties, ActiveDirectory.SingleSearchResult).FirstOrDefault();
}
@@ -17,7 +17,7 @@ namespace Disco.Services.Interop.ActiveDirectory
public string DistinguishedName { get; private set; }
public SecurityIdentifier SecurityIdentifier { get; private set; }
public string Id { get { return string.Format(@"{0}\{1}", Domain.NetBiosName, SamAccountName); } }
public string SamAccountName { get; private set; }
@@ -26,7 +26,9 @@ namespace Disco.Services.Interop.ActiveDirectory
public List<string> MemberOf { get; private set; }
private ADGroup(ADDomain Domain, string DistinguishedName, SecurityIdentifier SecurityIdentifier, string SamAccountName, string Name, List<string> MemberOf)
public Dictionary<string, object[]> LoadedProperties { get; private set; }
private ADGroup(ADDomain Domain, string DistinguishedName, SecurityIdentifier SecurityIdentifier, string SamAccountName, string Name, List<string> MemberOf, Dictionary<string, object[]> LoadedProperties)
{
this.Domain = Domain;
this.DistinguishedName = DistinguishedName;
@@ -34,9 +36,10 @@ namespace Disco.Services.Interop.ActiveDirectory
this.SamAccountName = SamAccountName;
this.Name = Name;
this.MemberOf = MemberOf;
this.LoadedProperties = LoadedProperties;
}
public static ADGroup FromSearchResult(ADSearchResult SearchResult)
public static ADGroup FromSearchResult(ADSearchResult SearchResult, string[] AdditionalProperties)
{
if (SearchResult == null)
throw new ArgumentNullException("SearchResult");
@@ -47,10 +50,21 @@ namespace Disco.Services.Interop.ActiveDirectory
var objectSid = new SecurityIdentifier(SearchResult.Value<byte[]>("objectSid"), 0);
var memberOf = SearchResult.Values<string>("memberOf").ToList();
return new ADGroup(SearchResult.Domain, distinguishedName, objectSid, sAMAccountName, name, memberOf);
// Additional Properties
Dictionary<string, object[]> additionalProperties;
if (AdditionalProperties != null)
additionalProperties = AdditionalProperties
.Select(p => Tuple.Create(p, SearchResult.Values<object>(p).ToArray()))
.ToDictionary(t => t.Item1, t => t.Item2);
else
{
additionalProperties = new Dictionary<string, object[]>();
}
return new ADGroup(SearchResult.Domain, distinguishedName, objectSid, sAMAccountName, name, memberOf, additionalProperties);
}
public static ADGroup FromDirectoryEntry(ADDirectoryEntry DirectoryEntry)
public static ADGroup FromDirectoryEntry(ADDirectoryEntry DirectoryEntry, string[] AdditionalProperties)
{
if (DirectoryEntry == null)
throw new ArgumentNullException("DirectoryEntry");
@@ -63,7 +77,50 @@ namespace Disco.Services.Interop.ActiveDirectory
var objectSid = new SecurityIdentifier(properties.Value<byte[]>("objectSid"), 0);
var memberOf = properties.Values<string>("memberOf").ToList();
return new ADGroup(DirectoryEntry.Domain, distinguishedName, objectSid, sAMAccountName, name, memberOf);
Dictionary<string, object[]> additionalProperties;
if (AdditionalProperties != null)
additionalProperties = AdditionalProperties
.Select(p => Tuple.Create(p, properties.Values<object>(p).ToArray()))
.ToDictionary(t => t.Item1, t => t.Item2);
else
{
additionalProperties = new Dictionary<string, object[]>();
}
return new ADGroup(DirectoryEntry.Domain, distinguishedName, objectSid, sAMAccountName, name, memberOf, additionalProperties);
}
[Obsolete("Use generic equivalents: GetPropertyValue<T>(string PropertyName)")]
public object GetPropertyValue(string PropertyName, int Index = 0)
{
return GetPropertyValues<object>(PropertyName).Skip(Index).FirstOrDefault();
}
public T GetPropertyValue<T>(string PropertyName)
{
return GetPropertyValues<T>(PropertyName).FirstOrDefault();
}
public IEnumerable<T> GetPropertyValues<T>(string PropertyName)
{
switch (PropertyName.ToLower())
{
case "name":
return new string[] { this.Name }.OfType<T>();
case "samaccountname":
return new string[] { this.SamAccountName }.OfType<T>();
case "distinguishedname":
return new string[] { this.DistinguishedName }.OfType<T>();
case "objectsid":
return new SecurityIdentifier[] { this.SecurityIdentifier }.OfType<T>();
case "memberof":
return this.MemberOf.OfType<T>();
default:
object[] adProperty;
if (this.LoadedProperties.TryGetValue(PropertyName, out adProperty))
return adProperty.OfType<T>();
else
return Enumerable.Empty<T>();
}
}
public override string ToString()
@@ -104,26 +104,35 @@ namespace Disco.Services.Interop.ActiveDirectory
};
}
[Obsolete("Use generic equivalents: GetPropertyValue<T>(string PropertyName)")]
public object GetPropertyValue(string PropertyName, int Index = 0)
{
return GetPropertyValues<object>(PropertyName).Skip(Index).FirstOrDefault();
}
public T GetPropertyValue<T>(string PropertyName)
{
return GetPropertyValues<T>(PropertyName).FirstOrDefault();
}
public IEnumerable<T> GetPropertyValues<T>(string PropertyName)
{
switch (PropertyName.ToLower())
{
case "name":
return this.Name;
return new string[] { this.Name }.OfType<T>();
case "samaccountname":
return this.SamAccountName;
return new string[] { this.SamAccountName }.OfType<T>();
case "distinguishedname":
return this.DistinguishedName;
return new string[] { this.DistinguishedName }.OfType<T>();
case "objectsid":
return this.SecurityIdentifier.ToString();
return new SecurityIdentifier[] { this.SecurityIdentifier }.OfType<T>();
case "netbootguid":
return this.NetbootGUID;
return new Guid[] { this.NetbootGUID }.OfType<T>();
default:
object[] adProperty;
if (this.LoadedProperties.TryGetValue(PropertyName, out adProperty) && Index <= adProperty.Length)
return adProperty[Index];
if (this.LoadedProperties.TryGetValue(PropertyName, out adProperty))
return adProperty.OfType<T>();
else
return null;
return Enumerable.Empty<T>();
}
}
@@ -0,0 +1,102 @@
using Disco.Data.Repository;
using Disco.Models.Services.Interop.ActiveDirectory;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
namespace Disco.Services.Interop.ActiveDirectory
{
public abstract class ADManagedGroup : IDisposable
{
public string Key { get; private set; }
public ADManagedGroupConfiguration Configuration { get; private set; }
internal ActiveDirectoryManagedGroups Context { get; set; }
public abstract string Description { get; }
public abstract string CategoryDescription { get; }
public abstract string GroupDescription { get; }
public abstract bool IncludeFilterBeginDate { get; }
public ADManagedGroup(string Key, ADManagedGroupConfiguration Configuration)
{
if (string.IsNullOrWhiteSpace(Key))
throw new ArgumentNullException("Key");
if (Configuration == null)
throw new ArgumentNullException("Configuration");
if (!ActiveDirectory.IsValidDomainAccountId(Configuration.GroupId))
throw new ArgumentException("Configuration.GroupId is not a valid Domain Account Id", "Configuration");
this.Key = Key;
this.Configuration = Configuration;
}
public abstract void Initialize();
public abstract IEnumerable<string> DetermineMembers(DiscoDataContext Database);
public ADGroup GetGroup()
{
return ActiveDirectory.RetrieveADGroup(this.Configuration.GroupId, "member");
}
protected void AddMember(string Id)
{
AddMember(Id, null);
}
protected void AddMember(string InvokingIdentifier, Func<DiscoDataContext, IEnumerable<string>> MemberResolver)
{
if (Context == null)
return; // Must be added to ActiveDirectoryManagedGroups
var action = new ADManagedGroupScheduledAction(
this,
ADManagedGroupScheduledActionType.AddGroupMember,
InvokingIdentifier,
MemberResolver);
Context.ScheduleAction(action);
}
protected void RemoveMember(string Id)
{
RemoveMember(Id, null);
}
protected void RemoveMember(string InvokingIdentifier, Func<DiscoDataContext, IEnumerable<string>> MemberResolver)
{
if (Context == null)
return; // Must be added to ActiveDirectoryManagedGroups
var action = new ADManagedGroupScheduledAction(
this,
ADManagedGroupScheduledActionType.RemoveGroupMember,
InvokingIdentifier,
MemberResolver);
Context.ScheduleAction(action);
}
public static ADManagedGroupConfiguration ConfigurationFromJson(string ConfigurationJson)
{
return JsonConvert.DeserializeObject<ADManagedGroupConfiguration>(ConfigurationJson);
}
public static string ValidConfigurationToJson(string GroupKey, string GroupId, DateTime? FilterBeginDate)
{
if (string.IsNullOrWhiteSpace(GroupId))
GroupId = null;
if (GroupId != null)
GroupId = ActiveDirectory.Context.ManagedGroups.ValidateGroupId(GroupId, GroupKey);
if (GroupId == null)
return null;
else
return JsonConvert.SerializeObject(new ADManagedGroupConfiguration()
{
GroupId = GroupId,
FilterBeginDate = FilterBeginDate
}, new JsonSerializerSettings() { DefaultValueHandling = DefaultValueHandling.Ignore });
}
public abstract void Dispose();
}
}
@@ -0,0 +1,76 @@
using Disco.Data.Repository;
using Disco.Services.Logging;
using Disco.Services.Tasks;
using Quartz;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Disco.Services.Interop.ActiveDirectory
{
public class ADManagedGroupsSyncTask : ScheduledTask
{
public override string TaskName { get { return "Active Directory - Synchronise Managed Groups"; } }
public override bool SingleInstanceTask { get { return true; } }
public override bool CancelInitiallySupported { get { return false; } }
public override void InitalizeScheduledTask(DiscoDataContext Database)
{
// ADManagedGroupsSyncTask @ 11:00pm
TriggerBuilder triggerBuilder = TriggerBuilder.Create().
WithSchedule(CronScheduleBuilder.DailyAtHourAndMinute(23, 0));
this.ScheduleTask(triggerBuilder);
}
protected override void ExecuteTask()
{
int changeCount;
List<ADManagedGroup> managedGroups = this.ExecutionContext.JobDetail.JobDataMap["ManagedGroups"] as List<ADManagedGroup>;
if (managedGroups == null)
managedGroups = ActiveDirectory.Context.ManagedGroups.Values;
this.Status.UpdateStatus(0, "Synchronising Active Directory Managed Groups", "Starting");
changeCount = ActiveDirectory.Context.ManagedGroups.SyncManagedGroups(managedGroups, this.Status);
SystemLog.LogInformation(new string[]
{
"Synchronised Active Directory Managed Groups",
changeCount.ToString()
});
this.Status.SetFinishedMessage(string.Format("Made {0} Changes to Active Directory Groups", changeCount));
}
public static ScheduledTaskStatus ScheduleSync(ADManagedGroup ManagedGroup)
{
if (ManagedGroup == null)
throw new ArgumentNullException("ManagedGroup");
JobDataMap taskData = new JobDataMap() {
{"ManagedGroups", new List<ADManagedGroup> { ManagedGroup } }
};
var instance = new ADManagedGroupsSyncTask();
return instance.ScheduleTask(taskData);
}
public static ScheduledTaskStatus ScheduleSync(IEnumerable<ADManagedGroup> ManagedGroups)
{
if (ManagedGroups == null)
throw new ArgumentNullException("ManagedGroups");
JobDataMap taskData = new JobDataMap() {
{"ManagedGroups", ManagedGroups.ToList() }
};
var instance = new ADManagedGroupsSyncTask();
return instance.ScheduleTask(taskData);
}
public static ScheduledTaskStatus ScheduleSyncAll()
{
var instance = new ADManagedGroupsSyncTask();
return instance.ScheduleTask();
}
}
}
@@ -12,16 +12,15 @@ using System.Linq;
namespace Disco.Services.Interop.ActiveDirectory
{
public class ADTaskUpdateNetworkLogonDates : ScheduledTask
public class ADNetworkLogonDatesUpdateTask : ScheduledTask
{
public override string TaskName { get { return "Active Directory - Update Last Network Logon Dates Task"; } }
public override bool SingleInstanceTask { get { return true; } }
public override bool CancelInitiallySupported { get { return false; } }
public override void InitalizeScheduledTask(DiscoDataContext Database)
{
// ActiveDirectoryUpdateLastNetworkLogonDateJob @ 11:30pm
// ADNetworkLogonDatesUpdateTask @ 11:30pm
TriggerBuilder triggerBuilder = TriggerBuilder.Create().
WithSchedule(CronScheduleBuilder.DailyAtHourAndMinute(23, 30));
@@ -50,11 +49,11 @@ namespace Disco.Services.Interop.ActiveDirectory
public static ScheduledTaskStatus ScheduleImmediately()
{
var existingTask = ScheduledTasks.GetTaskStatuses(typeof(ADTaskUpdateNetworkLogonDates)).Where(s => s.IsRunning).FirstOrDefault();
var existingTask = ScheduledTasks.GetTaskStatuses(typeof(ADNetworkLogonDatesUpdateTask)).Where(s => s.IsRunning).FirstOrDefault();
if (existingTask != null)
return existingTask;
var instance = new ADTaskUpdateNetworkLogonDates();
var instance = new ADNetworkLogonDatesUpdateTask();
return instance.ScheduleTask();
}
@@ -68,16 +67,18 @@ namespace Disco.Services.Interop.ActiveDirectory
if (!string.IsNullOrEmpty(Device.DeviceDomainId) && Device.DeviceDomainId.Contains('\\'))
{
var context = ActiveDirectory.Context;
var deviceSamAccountName = UserExtensions.SplitUserId(Device.DeviceDomainId).Item2 + "$";
var ldapFilter = string.Format(ldapFilterTemplate, ADHelpers.EscapeLdapQuery(deviceSamAccountName));
string deviceSamAccountName;
ADDomain deviceDomain;
var domain = context.GetDomainFromId(Device.DeviceDomainId);
ActiveDirectory.ParseDomainAccountId(Device.DeviceDomainId + "$", out deviceSamAccountName, out deviceDomain);
var ldapFilter = string.Format(ldapFilterTemplate, ADHelpers.EscapeLdapQuery(deviceSamAccountName));
IEnumerable<ADDomainController> domainControllers;
if (context.SearchAllForestServers)
domainControllers = domain.GetAllReachableDomainControllers();
domainControllers = deviceDomain.GetAllReachableDomainControllers();
else
domainControllers = domain.GetReachableSiteDomainControllers();
domainControllers = deviceDomain.GetReachableSiteDomainControllers();
lastLogon = domainControllers.Select(dc =>
{
@@ -111,32 +111,41 @@ namespace Disco.Services.Interop.ActiveDirectory
additionalProperties);
}
[Obsolete("Use generic equivalents: GetPropertyValue<T>(string PropertyName)")]
public object GetPropertyValue(string PropertyName, int Index = 0)
{
return GetPropertyValues<object>(PropertyName).Skip(Index).FirstOrDefault();
}
public T GetPropertyValue<T>(string PropertyName)
{
return GetPropertyValues<T>(PropertyName).FirstOrDefault();
}
public IEnumerable<T> GetPropertyValues<T>(string PropertyName)
{
switch (PropertyName.ToLower())
{
case "name":
return this.Name;
return new string[] { this.Name }.OfType<T>();
case "samaccountname":
return this.SamAccountName;
return new string[] { this.SamAccountName }.OfType<T>();
case "distinguishedname":
return this.DistinguishedName;
return new string[] { this.DistinguishedName }.OfType<T>();
case "objectsid":
return this.SecurityIdentifier.ToString();
return new SecurityIdentifier[] { this.SecurityIdentifier }.OfType<T>();
case "sn":
return this.Surname;
return new string[] { this.Surname }.OfType<T>();
case "givenname":
return this.GivenName;
return new string[] { this.GivenName }.OfType<T>();
case "mail":
return this.Email;
return new string[] { this.Email }.OfType<T>();
case "telephonenumber":
return this.Phone;
return new string[] { this.Phone }.OfType<T>();
default:
object[] adProperty;
if (this.LoadedProperties.TryGetValue(PropertyName, out adProperty) && Index <= adProperty.Length)
return adProperty[Index];
if (this.LoadedProperties.TryGetValue(PropertyName, out adProperty))
return adProperty.OfType<T>();
else
return null;
return Enumerable.Empty<T>();
}
}
@@ -114,23 +114,23 @@ namespace Disco.Services.Interop.ActiveDirectory
#region Groups
public static ADGroup RetrieveADGroup(string Id)
public static ADGroup RetrieveADGroup(string Id, params string[] AdditionalProperties)
{
var domain = Context.GetDomainFromId(Id);
return domain.GetAvailableDomainController().RetrieveADGroup(Id);
return domain.GetAvailableDomainController().RetrieveADGroup(Id, AdditionalProperties);
}
public static ADGroup RetrieveADGroupByDistinguishedName(string DistinguishedName)
public static ADGroup RetrieveADGroupByDistinguishedName(string DistinguishedName, params string[] AdditionalProperties)
{
var domain = Context.GetDomainFromDistinguishedName(DistinguishedName);
return domain.GetAvailableDomainController().RetrieveADGroupByDistinguishedName(DistinguishedName);
return domain.GetAvailableDomainController().RetrieveADGroupByDistinguishedName(DistinguishedName, AdditionalProperties);
}
public static ADGroup RetrieveADGroupWithSecurityIdentifier(SecurityIdentifier SecurityIdentifier)
public static ADGroup RetrieveADGroupWithSecurityIdentifier(SecurityIdentifier SecurityIdentifier, params string[] AdditionalProperties)
{
var domain = Context.GetDomainFromSecurityIdentifier(SecurityIdentifier);
return domain.GetAvailableDomainController().RetrieveADGroupWithSecurityIdentifier(SecurityIdentifier);
return domain.GetAvailableDomainController().RetrieveADGroupWithSecurityIdentifier(SecurityIdentifier, AdditionalProperties);
}
public static IEnumerable<ADGroup> SearchADGroups(string Term, int? ResultLimit = ActiveDirectory.DefaultSearchResultLimit)
public static IEnumerable<ADGroup> SearchADGroups(string Term, int? ResultLimit = ActiveDirectory.DefaultSearchResultLimit, params string[] AdditionalProperties)
{
if (string.IsNullOrWhiteSpace(Term))
throw new ArgumentNullException("Term");
@@ -141,7 +141,7 @@ namespace Disco.Services.Interop.ActiveDirectory
if (string.IsNullOrWhiteSpace(term))
return Enumerable.Empty<ADGroup>();
var ldapFilter= string.Format(ADGroup.LdapSearchFilterTemplate, ADHelpers.EscapeLdapQuery(term));
var ldapFilter = string.Format(ADGroup.LdapSearchFilterTemplate, ADHelpers.EscapeLdapQuery(term));
IEnumerable<ADSearchResult> searchResults;
if (searchDomain != null)
@@ -149,7 +149,7 @@ namespace Disco.Services.Interop.ActiveDirectory
else
searchResults = Context.SearchScope(ldapFilter, ADGroup.LoadProperties, ResultLimit);
return searchResults.Select(result => result.AsADGroup());
return searchResults.Select(result => result.AsADGroup(AdditionalProperties));
}
#endregion
@@ -185,6 +185,124 @@ namespace Disco.Services.Interop.ActiveDirectory
#region Helpers
public static string ParseDomainAccountId(string AccountId)
{
return ParseDomainAccountId(AccountId, null);
}
public static string ParseDomainAccountId(string AccountId, string AccountDomain)
{
string accountUsername;
ADDomain domain;
return ParseDomainAccountId(AccountId, AccountDomain, out accountUsername, out domain);
}
public static string ParseDomainAccountId(string AccountId, out string AccountUsername)
{
return ParseDomainAccountId(AccountId, null, out AccountUsername);
}
public static string ParseDomainAccountId(string AccountId, string AccountDomain, out string AccountUsername)
{
ADDomain domain;
return ParseDomainAccountId(AccountId, AccountDomain, out AccountUsername, out domain);
}
public static string ParseDomainAccountId(string AccountId, out ADDomain Domain)
{
return ParseDomainAccountId(AccountId, null, out Domain);
}
public static string ParseDomainAccountId(string AccountId, string AccountDomain, out ADDomain Domain)
{
string accountUsername;
return ParseDomainAccountId(AccountId, AccountDomain, out accountUsername, out Domain);
}
public static string ParseDomainAccountId(string AccountId, out string AccountUsername, out ADDomain Domain)
{
return ParseDomainAccountId(AccountId, null, out AccountUsername, out Domain);
}
public static string ParseDomainAccountId(string AccountId, string AccountDomain, out string AccountUsername, out ADDomain Domain)
{
if (string.IsNullOrWhiteSpace(AccountId))
throw new ArgumentNullException("AccountId");
var slashIndex = AccountId.IndexOf('\\');
if (slashIndex < 0 && !string.IsNullOrWhiteSpace(AccountDomain))
{
AccountId = AccountDomain + @"\" + AccountId;
slashIndex = AccountDomain.Length;
}
if (slashIndex < 0)
{
AccountUsername = AccountId;
Domain = Context.PrimaryDomain;
}
else
{
AccountUsername = AccountId.Substring(slashIndex + 1);
Domain = Context.GetDomainByNetBiosName(AccountId.Substring(0, slashIndex));
}
return string.Concat(Domain.NetBiosName, @"\", AccountUsername);
}
public static bool IsValidDomainAccountId(string AccountId)
{
string accountUsername;
ADDomain domain;
return IsValidDomainAccountId(AccountId, out accountUsername, out domain);
}
public static bool IsValidDomainAccountId(string AccountId, out string AccountUsername)
{
ADDomain domain;
return IsValidDomainAccountId(AccountId, out AccountUsername, out domain);
}
public static bool IsValidDomainAccountId(string AccountId, out ADDomain Domain)
{
string accountUsername;
return IsValidDomainAccountId(AccountId, out accountUsername, out Domain);
}
public static bool IsValidDomainAccountId(string AccountId, out string AccountUsername, out ADDomain Domain)
{
if (string.IsNullOrEmpty(AccountId))
{
AccountUsername = null;
Domain = null;
return false;
}
var slashIndex = AccountId.IndexOf('\\');
if (slashIndex < 0)
{
AccountUsername = AccountId;
Domain = null;
return false;
}
else
{
AccountUsername = AccountId.Substring(slashIndex + 1);
return ActiveDirectory.Context.TryGetDomainByNetBiosName(AccountId.Substring(0, slashIndex), out Domain);
}
}
/// <summary>
/// If the AccountId Domain matches the Primary Domain, returns the Account Username without the Domain specified
/// </summary>
/// <returns></returns>
public static string FriendlyAccountId(string AccountId)
{
var slashIndex = AccountId.IndexOf('\\');
if (slashIndex > 0 && AccountId.Substring(0, slashIndex).Equals(ActiveDirectory.Context.PrimaryDomain.NetBiosName, StringComparison.OrdinalIgnoreCase))
return AccountId.Substring(slashIndex + 1);
else
return AccountId;
}
private static string RelevantSearchTerm(string Term, out ADDomain Domain)
{
Domain = null;
@@ -15,6 +15,7 @@ namespace Disco.Services.Interop.ActiveDirectory
public ADSite Site { get; private set; }
public ADDomain PrimaryDomain { get; private set; }
public List<ADDomain> Domains { get; private set; }
public ActiveDirectoryManagedGroups ManagedGroups { get; private set; }
public List<string> ForestServers
{
@@ -39,7 +40,12 @@ namespace Disco.Services.Interop.ActiveDirectory
#region Contructor/Initializing
internal ActiveDirectoryContext(DiscoDataContext Database)
private ActiveDirectoryContext()
{
ManagedGroups = new ActiveDirectoryManagedGroups();
}
internal ActiveDirectoryContext(DiscoDataContext Database) : this()
{
Initialize(Database);
}
@@ -138,24 +144,24 @@ namespace Disco.Services.Interop.ActiveDirectory
if (string.IsNullOrWhiteSpace(Id))
throw new ArgumentNullException("Id");
var idSplit = UserExtensions.SplitUserId(Id);
var slashIndex = Id.IndexOf('\\');
if (string.IsNullOrWhiteSpace(idSplit.Item1))
if (slashIndex < 0)
throw new ArgumentException(string.Format("The Id must include the Domain [{0}]", Id), "Id");
return TryGetDomainByNetBiosName(idSplit.Item1, out Domain);
return TryGetDomainByNetBiosName(Id.Substring(0, slashIndex), out Domain);
}
public ADDomain GetDomainFromId(string Id)
{
if (string.IsNullOrWhiteSpace(Id))
throw new ArgumentNullException("Id");
var idSplit = UserExtensions.SplitUserId(Id);
var slashIndex = Id.IndexOf('\\');
if (string.IsNullOrWhiteSpace(idSplit.Item1))
if (slashIndex < 0)
throw new ArgumentException(string.Format("The Id must include the Domain [{0}]", Id), "Id");
return GetDomainByNetBiosName(idSplit.Item1);
return GetDomainByNetBiosName(Id.Substring(0, slashIndex));
}
#endregion
@@ -69,21 +69,21 @@ namespace Disco.Services.Interop.ActiveDirectory
}
// Groups
public static ADGroup AsADGroup(this ADSearchResult SearchResult)
public static ADGroup AsADGroup(this ADSearchResult SearchResult, string[] AdditionalProperties)
{
return ADGroup.FromSearchResult(SearchResult);
return ADGroup.FromSearchResult(SearchResult, AdditionalProperties);
}
public static IEnumerable<ADGroup> AsADGroups(this IEnumerable<ADSearchResult> SearchResults)
public static IEnumerable<ADGroup> AsADGroups(this IEnumerable<ADSearchResult> SearchResults, string[] AdditionalProperties)
{
return SearchResults.Select(sr => ADGroup.FromSearchResult(sr));
return SearchResults.Select(sr => ADGroup.FromSearchResult(sr, AdditionalProperties));
}
public static ADGroup AsADGroup(this ADDirectoryEntry DirectoryEntry)
public static ADGroup AsADGroup(this ADDirectoryEntry DirectoryEntry, string[] AdditionalProperties)
{
return ADGroup.FromDirectoryEntry(DirectoryEntry);
return ADGroup.FromDirectoryEntry(DirectoryEntry, AdditionalProperties);
}
public static IEnumerable<ADGroup> AsADGroups(this IEnumerable<ADDirectoryEntry> DirectoryEntries)
public static IEnumerable<ADGroup> AsADGroups(this IEnumerable<ADDirectoryEntry> DirectoryEntries, string[] AdditionalProperties)
{
return DirectoryEntries.Select(de => ADGroup.FromDirectoryEntry(de));
return DirectoryEntries.Select(de => ADGroup.FromDirectoryEntry(de, AdditionalProperties));
}
// Organisational Units
@@ -0,0 +1,483 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Subjects;
using System.Text;
using System.Threading.Tasks;
using Disco.Data.Repository;
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(string.Format("[{0}] cannot manage this group [{1}] because is already managed by [{2}]", ManagedGroup.Key, ManagedGroup.Configuration.GroupId, 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 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(string.Format("The group [{0}] wasn't found", GroupId), "DevicesLinkedGroup");
if (group.GetPropertyValue<bool>("isCriticalSystemObject"))
throw new ArgumentException(string.Format("The group [{0}] is a Critical System Active Directory Object and Disco refuses to modify it", group.DistinguishedName), "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(string.Format("Cannot manage this group [{0}] because is already managed by [{1}]", GroupId, 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 = string.Format(@"{0}\{1}", 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 = string.Format("(&(|(objectCategory=computer)(objectCategory=person))(sAMAccountName={0}))", 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(string.Format("This group [{0}] is a Critical System Active Directory Object and Disco refuses to modify it", adGroup.DistinguishedName));
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
string.Format("Determining Group Members: {0} [{1}]", 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 = string.Format(@"{0}\{1}", 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
string.Format("Resolving {0} Group Members: {1} [{2}]", g.Item2.Count, 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 = string.Format("(&(|(objectCategory=computer)(objectCategory=person))(sAMAccountName={0}))", 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
string.Format("Synchronizing {0} Group Members: {1} [{2}]", actionGroup.Item2.Count, 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(string.Format("This group [{0}] is a Critical System Active Directory Object and Disco refuses to modify it", adGroup.DistinguishedName));
// Update Description
var groupDescription = string.Format("Disco ICT: {0}", 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;
this.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(this.ManagedGroup, this.ActionType, m)
);
}
else
{
return new ADManagedGroupScheduledActionItem[]
{
new ADManagedGroupScheduledActionItem(this.ManagedGroup, this.ActionType, this.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
}
}
@@ -0,0 +1 @@