Gabe's Code

Stuff I've learned along the way

Active Directory

more...

profile for Gabriel Luci at Stack Overflow, Q&A for professional and enthusiast programmers

Active Directory: Find all the members of a group

This article will discuss finding all the members of a group. While the code is in C#, the principals can be applied to any language that can make LDAP queries.

But before learning that, it’s helpful to know just what makes a user a member of a group. If you haven’t read that article yet, do that first:

What makes a member a member?

The code

System.DirectoryServices.AccountManagement

If you have read any of my other articles, you’ll know I’m not a fan of the AccountManagement namespace. It can be simpler, but comes at a cost of performance and sometimes functionality. But here’s an example anyway (assuming you already have a GroupPrincipal object):

public static IEnumerable<string> GetGroupMemberList(GroupPrincipal group, bool recursive = false) {
    using (var memberPrincipals = group.GetMembers(recursive)) {
        foreach (Principal member in memberPrincipals) {
            yield return member.SamAccountName;
        }
    }
}

It’s pretty short! But there are a few caveats:

  1. There is a lot more network traffic going on in behind than you actually need (it pulls in all attributes that have a value even though we’re only using SamAccountName)
  2. It will crash if the group contains members from external trusted domains (i.e. Foreign Security Principals)

System.DirectoryServices

It is more code, but if you use DirectoryEntry, you get far better performance, and you can make it actually work for Foreign Security Principals.

These examples will output the accounts in DOMAIN\username format, but you can modify for whatever you need.

Single-forest environments

First, here is an example if you are working only in a single-forest environment (where you won’t have any Foreign Security Principals).

If you want to expand groups that are inside this group, pass true for the recursive parameter. These examples assume you already have a DirectoryEntry object for the group.

public static IEnumerable<string> GetGroupMemberList(DirectoryEntry group, bool recursive = false) {
    var members = new List<string>();

    group.RefreshCache(new[] { "member" });

    var membersFound = 0;
    while (true) {
        var memberDns = group.Properties["member"];
        foreach (string member in memberDns) {
            using (var memberDe = new DirectoryEntry($"LDAP://{member.Replace("/", "\\/")}")) {
                memberDe.RefreshCache(new[] { "objectClass", "msDS-PrincipalName", "cn" });

                if (recursive && memberDe.Properties["objectClass"].Contains("group")) {
                    members.AddRange(GetGroupMemberList(memberDe, true));
                } else {
                    var username = memberDe.Properties["msDS-PrincipalName"].Value.ToString();
                    if (!string.IsNullOrEmpty(username)) {
                        members.Add(username);
                    }
                }
            }
        }

        if (memberDns.Count == 0) break;
        membersFound += memberDns.Count;

        try {
            group.RefreshCache(new[] {$"member;range={membersFound}-*"});
        } catch (COMException e) {
            if (e.ErrorCode == unchecked((int) 0x80072020)) { //no more results
                break;
            }
            throw;
        }
    }
    return members;
}

Finding foreign members

If you need to account for Foreign Security Principals, they are a little tricky. FSP’s contain the SID of the object on the external domain. You can bind directly to an object using the SID by using LDAP://<SID={sid}>, but for objects on an external domain, you also have to include the DNS name of the domain: LDAP://domain.com/<SID={sid}>. Because of that, we need to know the DNS name of the domain ahead of time.

The SID will actually tell you the domain because the first part of the SID is specific to the domain, whereas the very last section of numbers in the SID is specific to the object. So in this method, we first look up all the domain trusts and create a mapping table between each domain’s SID and its DNS name.

The code that looks for the domains (like Domain.GetDomain() and domain.GetAllTrustRelationships()) make calls out to AD to find that information. To gain performance, you can either hard-code the domain names in (if your code will only be run in one AD environment) or cache them the first time you find them.

public static IEnumerable<string> GetGroupMemberList(DirectoryEntry group, bool recursive = false, Dictionary<string, string> domainSidMapping = null) {
    var members = new List<string>();

    group.RefreshCache(new[] { "member", "canonicalName" });

    if (domainSidMapping == null) {
        //Find all the trusted domains and create a dictionary that maps the domain's SID to its DNS name
        var groupCn = (string) group.Properties["canonicalName"].Value;
        var domainDns = groupCn.Substring(0, groupCn.IndexOf("/", StringComparison.Ordinal));

        var domain = Domain.GetDomain(new DirectoryContext(DirectoryContextType.Domain, domainDns));
        var trusts = domain.GetAllTrustRelationships();

        domainSidMapping = new Dictionary<string, string>();

        foreach (TrustRelationshipInformation trust in trusts) {
            using (var trustedDomain = new DirectoryEntry($"LDAP://{trust.TargetName}")) {
                try {
                    trustedDomain.RefreshCache(new [] {"objectSid"});
                    var domainSid = new SecurityIdentifier((byte[]) trustedDomain.Properties["objectSid"].Value, 0).ToString();
                    domainSidMapping.Add(domainSid, trust.TargetName);
                } catch (Exception e) {
                    //This can happen if you're running this with credentials
                    //that aren't trusted on the other domain or if the domain
                    //can't be contacted
                    throw new Exception($"Can't connect to domain {trust.TargetName}: {e.Message}", e);
                }
            }
        }
    }

    var membersFound = 0;
    while (true) {
        var memberDns = group.Properties["member"];
        foreach (string member in memberDns) {
            using (var memberDe = new DirectoryEntry($"LDAP://{member.Replace("/", "\\/")}")) {
                memberDe.RefreshCache(new[] { "objectClass", "msDS-PrincipalName", "cn" });

                if (recursive && memberDe.Properties["objectClass"].Contains("group")) {
                    members.AddRange(GetGroupMemberList(memberDe, true, domainSidMapping));
                } else if (memberDe.Properties["objectClass"].Contains("foreignSecurityPrincipal")) {
                    //User is on a trusted domain
                    var foreignUserSid = memberDe.Properties["cn"].Value.ToString();
                    //The SID of the domain is the SID of the user minus the last block of numbers
                    var foreignDomainSid = foreignUserSid.Substring(0, foreignUserSid.LastIndexOf("-"));
                    if (domainSidMapping.TryGetValue(foreignDomainSid, out var foreignDomainDns)) {
                        using (var foreignMember = new DirectoryEntry($"LDAP://{foreignDomainDns}/<SID={foreignUserSid}>")) {
                            foreignMember.RefreshCache(new[] { "msDS-PrincipalName", "objectClass" });
                            if (recursive && foreignMember.Properties["objectClass"].Contains("group")) {
                                members.AddRange(GetGroupMemberList(foreignMember, true, domainSidMapping));
                            } else {
                                members.Add(foreignMember.Properties["msDS-PrincipalName"].Value.ToString());
                            }
                        }
                    } else {
                        //unknown domain
                        members.Add(foreignUserSid);
                    }
                } else {
                    var username = memberDe.Properties["msDS-PrincipalName"].Value.ToString();
                    if (!string.IsNullOrEmpty(username)) {
                        members.Add(username);
                    }
                }
            }
        }

        if (memberDns.Count == 0) break;
        membersFound += memberDns.Count;

        try {
            group.RefreshCache(new[] {$"member;range={membersFound}-*"});
        } catch (COMException e) {
            if (e.ErrorCode == unchecked((int) 0x80072020)) { //no more results
                break;
            }
            throw;
        }
    }
    return members;
}

Primary groups

Neither of these methods will find users who have this group as the primary group. If you need that, here is a method that does it. If need be, you can combine this method with one of the ones above.

As discussed in my other article, this relationship is determined by the primaryGroupId attribute on the user account, so that’s what we search for. These users will always be on the same domain as the group.

public static IEnumerable<string> GetPrimaryGroupMemberList(DirectoryEntry group) {
    group.RefreshCache(new[] { "distinguishedName", "primaryGroupToken" });
    
    var groupDn = (string) group.Properties["distinguishedName"].Value;
    var ds = new DirectorySearcher(
        new DirectoryEntry($"LDAP://{groupDn.Substring(groupDn.IndexOf("DC=", StringComparison.Ordinal))}"),
        $"(&(objectClass=user)(primaryGroupId={group.Properties["primaryGroupToken"].Value}))",
        new [] { "msDS-PrincipalName" })
    {
        PageSize = 1000
    };
    
    using (var primaryMembers = ds.FindAll()) {
        foreach (SearchResult primaryMember in primaryMembers) {
            yield return (string) primaryMember.Properties["msDS-PrincipalName"][0];
        }
    }
}

20 comments

One little notice here. I assume that GetGroupMemberList method will end with stackoverflow if I have GroupA as a member of GroupB and GroupB as a member of GroupA. A simple HashSet solution won’t work here as users of GroupA are members of GroupB and users of GroupB are members of GroupA as well )

HashSet will work of course. My bad. It won’t work in case if you need to calculate memberOf using member.

Markus on

Hi, is there a proper way to get the domain and name, like SecurityIdentifier.Translate, but a bit faster? Thank you.

That’s what my examples above return: the “DOMAIN\Username”. It does so by getting the msDS-PrincipalName. That’s a constructed attribute, meaning that it is calculated at the time you ask for it. But that also means you can’t use it as a filter in a search.

Steven Teale on

Hello,
I suspect that this article will be a life save for me but i do need a little help to get me over the line with some working code.
Can you provide an example or two of a call to the GetGroupMemberList - the Foreign Security Principals version?
I need to be able to resolve these down to a domain, DN, etc. They may be from either of two other domains. They also may be groups or users.
I am still learning C# and also these AD objects and this will be a big help
Thanks very much, Steve

You just need to pass it a DirectoryEntry object pointing to the group. That’s all that’s required. For example:

var group = new DirectoryEntry("LDAP://CN=MyGroup,OU=Groups,DC=example,DC=com");
var members = GetGroupMemberList(group);

If you want a recursive list of members, then just pass true for the recursive parameter: GetGroupMemberList(group, true)

You don’t need to worry about the domainSidMapping parameter because it’s created inside the method. It’s only a parameter so that it can be passed when the method calls itself, saving time.

Steven Teale on

Thanks very much Gabe! I will give this a try just as soon as I can and let you know the result.

Is there a way to get all members of “Everyone” group? I tried above code but it does not work for this special group.

GetGroupMemberList (Single-forest environments) returns users twice ore more, if they are members of two or more recursive groups. In this case you should return the account only once.

I think there’s another rpoblem with this:

group.RefreshCache(new[] {$"member;range={members.Count}-*"});

If there are members in subgroups added to the members-List, the search on the root level will miss elements.

Thanks for this Gabe, this is very useful. Is there any way to change the connection to be on UDP using directory entry class? Currently It runs on TCP port 389 by default however due to some security restrictions i can only unblock port 389 on UDP so need to run this over that.

That would depend on the Active Directory server. You have to use TCP 389 because that’s what the AD server is listening on. UDP isn’t an option simply because it isn’t a good idea. TCP guarantees delivery of each packet, whereas UDP does not.

But is there a way to achieve this using UDP and make .net connect over that protocol instead of TCP?

I haven’t seen any way to tell DirectoryEntry to use UDP, and AD only has very limited support for UDP. See here: LDAP Search Over UDP: “Active Directory supports search over UDP only for searches against rootDSE.”

Hi! Thanks for this post, it’s helped me to greatly improve the speed of our code. I’m having some trouble with getting results when the group exceeds over 1500 members. And I’m struggling to see how I can implement a paging query or a simple skip and take.

Any thoughts on this?

My sample code above shows how to do the paging. First is asks for the members:

group.RefreshCache(new[] { "member" });

Then it enters a loop that eventually asks for the next batch, using this:

group.RefreshCache(new[] {$"member;range={membersFound}-*"});

On the first iteration of the loop, that will be "member;range=1500-*", meaning, “give me the members skipping the first 1500”. It continues in that loop until an error is thrown that indicates there are no more members.

Leave a comment

Your email address is used to display your Gravatar, if applicable, and subscribe you to replies using the Mailgun web service, which you are free to unsubscribe from when you get any emails. Your email address will not be displayed publicly or shared with anyone else.
Comments are moderated. Your comment will be reviewed by a human before being posted to this page. Any comment made for the purpose of advertising will not be approved.