How to get the Organization Units (OU) and Hosts from Microsoft Active Directory using Python ldap3

I recently figured out how to work with Microsoft Active Directory using Python 3. I wanted to get a hierarchy of Organizational Units (OUs) and all the network hosts associated with these OUs to search for possible anomalies. If you are not familiar with AD, here is a good thread about the difference between AD Group and OU.

It seems much easier to solve such tasks using PowerShell. But it will probably require a Windows server. So I leave this for the worst scenario. 🙂 There is also a PowerShell Core, which should support Linux, but I haven’t tried it yet. If you want to use Python, there is a choice from the native python ldap3 module and Python-ldap, which is a wrapper for the OpenLDAP client. I didn’t find any interesting high-level functions in Python-ldap and finally decided to use ldap3.

Connection

At first you will need to create a Connection to the server, which can be used in further requests:

#!/usr/bin/python
# -*- coding: utf-8 -*-

from ldap3 import Server, Connection, SUBTREE, LEVEL

server = Server("ad-server.corporation.com", port=389, use_ssl=False, get_info='ALL')
connection = Connection(server, user="user1", password="Password123",
               fast_decoder=True, auto_bind=True, auto_referrals=True, check_names=False, read_only=True,
               lazy=False, raise_exceptions=False)

When the connection is no longer needed, run:

connection.unbind()

Getting the child OUs

I did not find how to get the whole tree of Organizational Units using one high-level command. But I found out how to make a function, that returns all child OUs (basically their domain names) for the particular OU (domain name):

def get_child_ou_dns(dn, connection):
    results = list()
    elements = connection.extend.standard.paged_search(
        search_base=dn,
        search_filter='(objectCategory=organizationalUnit)',
        search_scope=LEVEL,
        paged_size=100)
    for element in elements:
        if 'dn' in element:
            if element['dn'] != dn:
                if 'dn' in element:
                    results.append(element['dn'])
    return(results)

Note the great feature of ldap3: with conn.extend.standard.paged_search you can search in AD without worrying about pagination. It’s very convenient. I also added if element['dn'] != dn, because, for some reason, the response may contain the element with the same dn that was set in the parameters of the function, so I skip it.

Example:

print(get_child_ou_dns(dn="OU=Computers Office,DC=corporation,DC=com", connection=connection))

Output:

[u'OU=NY,OU=Computers Office,DC=corporation,DC=com', u'OU=SLC,OU=Computers Office,DC=corporation,DC=com',
u'OU=London,OU=Computers Office,DC=corporation,DC=com', ...] 

Getting the hierarchy of OUs

As we see above, the entire hierarchy is contained in dn:

OU=NY,OU=Computers Office,DC=corporation,DC=com
DC=com, DC=corporation -> OU=Computers Office -> OU=NY

You can get all the OUs by setting search_base='DC=corporation,DC=com' and search_scope=SUBTREE in the search request and restore the hierarchy by parsing the DNs. But, it seem more reliable to do this through the sequence of searches. Because it is not clear how the DN is generated and a garbage we can meet there.

Instead of this, to get the whole tree of Organizational Units I go down through the all OUs starting from kernel (‘DC=corporation,DC=com’). It can be done with recursion, but I used cycles, because I think it’s more clear this way.

  • all_ous is the dict that contains all OUs with their child OUs. It’s what I want to get in result.
  • ou_dn_process_status is a dict with the need_to_process flag. This flag shows that we need to process a dn and get child OUs. At start it contains only ‘DC=corporation,DC=com’, the kernel, and others will be added in process.
  • has_searches_to_process is a flag that shows that there is at least one OU that can be processed.

So, if there is an OU in ou_dn_process_status dict with {'need_to_process':True}, I get the child OUs for it using get_child_ou_dns function, mark this OU entry with ['need_to_process'] = False, so it will be skipped next time, and mark the child OUs as ['need_to_process'] = True, so they will be processed next time. I continue this loop while there is at least one OU with ['need_to_process'] == True.

all_ous = dict()
ou_dn_process_status = dict()
ou_dn_process_status['DC=corporation,DC=com'] = {'need_to_process':True}
has_searches_to_process = True
while has_searches_to_process:
    ou_dn_process_status_keys = list(ou_dn_process_status.keys())
    for dn in ou_dn_process_status_keys:
        if ou_dn_process_status[dn]['need_to_process']:
            all_ous[dn] = get_child_ou_dns(dn, connection)
            ou_dn_process_status[dn]['need_to_process'] = False
            for child_ou_dn in all_ous[dn]:
                if not child_ou_dn in ou_dn_process_status:
                    ou_dn_process_status[child_ou_dn] = {'need_to_process':True}
    has_searches_to_process = False
    for dn in ou_dn_process_status:
        if ou_dn_process_status[dn]['need_to_process']:
            has_searches_to_process = True

Example:

all_ous['OU=Computers Office,DC=corporation,DC=com']

Output:

[u'OU=Vodnyj,OU=Computers Office,DC=corporation,DC=com', ... u'OU=Diapazon,OU=Computers Office,DC=corporation,DC=com',
u'OU=CYDEV,OU=Computers Office,DC=corporation,DC=com', u'OU=Conference,OU=Computers Office,DC=corporation,DC=com',
u'OU=_lock,OU=Computers Office,DC=corporation,DC=com']

Getting the hosts (computers)

All the hierarchy is presented by OUs, so it makes sense to get all the hosts in AD with only one command. I will use the same filter as in get_child_ou_dns function above but, with objectCategory=computer. I want all the hosts, so I set ‘DC=corporation,DC=com‘ as a search_base. To get only hosts in some specific OU you can specify it in search_base instead. The additional criterion “userAccountControl:1.2.840.113556.1.4.803:=2” in filter is used to exclude all disabled computer accounts, I took it from this article by BeyondTrust.

def get_all_ad_hosts(connection):  
    elements = connection.extend.standard.paged_search(
        search_base='DC=corporation,DC=com',
        search_filter='(&(objectCategory=computer)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))',
        search_scope=SUBTREE,
        attributes=['whenCreated', 'operatingSystem',
            'operatingSystemServicePack', 'name', 'lastLogon',
            'memberOf', 'whenChanged'],
        paged_size=100)
    for element in elements:
        host = dict()
        if 'dn' in element:
            host['dn'] = element['dn']
            host['name'] = element['attributes'][u'name'][0]
            host['memberOf'] = element['attributes'][u'memberOf']
            results.append(host)
    return(results)

Note if 'dn' in element, I do it for skipping the lines like {‘type’: ‘searchResRef’, ‘uri’: [u’ldap://corporation.com/CN=Configuration,DC=corporation,DC=com’]}

Example:

{u'dn': u'CN=CORP2912,OU=CORP2XXX,OU=Computers Office,DC=corporation,DC=com',
u'memberOf': [u'CN=computers_NY_group,OU=Locations,OU=Computers, OU=Security Groups,DC=corporation,DC=com',
u'CN=Important_Software1_Settings,OU=Security Groups,DC=corporation,DC=com',
u'CN=hrt53-vlan111,OU=Domain Groups,DC=corporation,DC=com'], u'name': u'CORP2912'}

So, basically that’s it. I assume that there is a python module that can draw these OUs and hosts as a beautiful tree, but I haven’t tried it yet. To check that the script works correctly I used GUI tool by Microsoft AD Explorer.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.