JAMFIT’s LDAP Sync Script - Converted from Python 2 to PowerShell

During the time that I spent on a temporary assignment on Blizzard’s End User Computing team, work began on implementing Jamf. Being a global company, Blizzard has more than a few office locations and departments that are all represented across Active Directory. A script that Jamf’s IT team hosts on GitHub enables easy syncing from AD to Jamf for these attributes, but it was undesirable to need Python 2 installed in order to run it.

I took on the task of converting the script to PowerShell and it’s been very useful in our environment so far. It depends on the ActiveDirectory module being available, but it’s fairly safe to assume that Windows administrators have the RSAT tools installed.

You can view the script here. The Pull Request to have this merged into Jamf’s repository alongside the Python 2 script has been accepted by Jamf, which was super exciting for me as a newbie in the open source community! Let’s go over the script and my decisions in its design.

Logging

When I was beginning work on the conversion, there was discussion of scheduling this script to run automatically on some interval to keep AD and the JSS (Jamf Software Server) synced regularly. An addition I made to the features of the Python 2 script is a Log function that writes to the console and additionally to a text file if the LogFile switch parameter is specified at runtime.

[CmdletBinding()]  
Param(  
  [Switch]$LogFile  
)

# Logging-related variables in script scope  
$Script:LogFile = $LogFile  
$Script:LogFolderPath = ".\Logs"  
$Script:LogFilePath = $LogFolderPath + "\" + (Get-Date -Format FileDate) + ".txt"

function Log ([String]$Content) {  
  Write-Host $Content  
  if ($Script:LogFile) {Add-Content -Path $Script:LogFilePath -Value $Content}  
}

Only 5 log files will be kept in the designated Logs directory; the script removes the fifth-oldest log before it begins work.

# Delete 5th oldest log file (if it exists) and create a new one.  
if ($Script:LogFile) {  
  if (!(Test-Path -Path $Script:LogFolderPath)) {  
    New-Item -ItemType Directory -Force -Path $Script:LogFolderPath | Out-Null  
  }

  Get-ChildItem -Path $Script:LogFolderPath | `  
  Where-Object -FilterScript {-not $_.PSIsContainer} | `  
  Sort-Object -Property CreationTime -Descending | `  
  Select-Object -Skip 4 | `  
  Remove-Item -Force | `  
  Out-Null

  New-Item -ItemType File -Path $Script:LogFilePath -Force | Out-Null  
}

Functions

The JSS API operates using REST operations and XML data, so I encapsulated each necessary operation and its accompanied API call in a function to keep the script legible. For the GetDepartments/GetBuildings, CreateDepartment/CreateBuilding, and DeleteDepartment/DeleteBuilding functions, the operations inside each pair of functions are identical except for department being replaced with building appropriately. I’ll only be presenting the department-related functions in these cases as to not be repetitive.

Getting Departments/Buildings from the JSS

The list of departments and buildings in the JSS is not of a fixed length, of course, so I begin each function by instantiating a hash table that can then be appended to with the Add method. Note: I really should have used System.Collections.ArrayList containers for this purpose as they are the most memory-efficient and time-efficient in this situation. Arrays can also be “appended” to using the += operator, but this instantiates a whole new array and copies the old one into it which is both memory and time inefficient. Using hash tables necessitated storing each department and building as a key/value pair, with the key being the name of the item and the value being $null - unnecessary and inefficient, but not to the point that any slowness is noticeable during execution.

Next, Invoke-WebRequest retrieves the departments/buildings stored in the JSS using the JSS URL and JSS admin credential that the user of the script defines. An attempt is made to cast the content of the response to an XML object from which the names of the departments/buildings are extracted; if this cast fails, there were no departments/buildings in the JSS and an empty list is outputted to the PowerShell pipeline. If the cast is successful, the XML hierarchy is iterated through and each department/building name is added to the hash table which is then outputted to the PowerShell pipeline.

# Returns a list of all departments in JAMF.  
function GetDepartments ([String]$JssUrl, [PSCredential]$Credential) {  
  $listDepartments = @{}  
  $requestResponse = Invoke-WebRequest -Uri "$JssUrl/departments" -Credential $Credential

  try {  
    $root = [Xml]$requestResponse.Content # cast content to XML  
    $departmentNodes = $root.SelectNodes("//department")  
  }  
  catch {  
    # There were no departments stored in JAMF  
    $departmentNodes = $null  
  }

  if ($departmentNodes -ne $null) {  
    foreach ($node in $departmentNodes) {  
    $listDepartments.Add($node.name, $null)  
    }  
  }

  $listDepartments  
}

Creating Departments/Buildings in the JSS

Due to the international presence of Blizzard Entertainment, initial testing of the script immediately presented an issue with creating departments and buildings in the JSS whose names contained characters that are invalid in XML. These departments and buildings would be created successfully, but on the next execution of the script the invalid characters would not exist in the JSS listing and consequently would not successfully match any of those in AD, resulting in the immediate deletion of the item from the JSS. I discovered the [System.Security.SecurityElement]::Escape static .NET method here which replaces any characters that are invalid in XML with their matching escape character. This process, when paired with a charset=utf-8 designation in the web request ContentType, allowed all of the departments and buildings to sync successfully and avoid accidental deletion.

# Creates a department in JAMF from the $Name parameter.  
function CreateDepartment ([String]$JssUrl, [PSCredential]$Credential, [String]$Name) {  
  $name = [System.Security.SecurityElement]::Escape($Name) # Replaces invalid XML characters  
  $body = "<department><name>$name</name></department>" # XML-formatted request body  
  $webRequestParams = @{  
    Uri = "$JssUrl/departments/id/0";  
    Credential = $Credential;  
    Method = "Post";  
    Body = $body;  
    ContentType = 'application/xml; charset=utf-8';  
  }

  $response = Invoke-WebRequest @webRequestParams  
}

Deleting Departments/Buildings in the JSS

Deletion of departments and buildings did not require any special processing to perform successfully, so the functions are much simpler than those previously described.

# Deletes a department from JAMF that matches the $Name parameter.  
function DeleteDepartment ([String]$JssUrl, [PSCredential]$Credential, [String]$Name) {  
  $webRequestParams = @{  
    Uri = "$JssUrl/departments/name/$Name";  
    Credential = $Credential;  
    Method = "Delete";  
  }

  $response = Invoke-WebRequest @webRequestParams  
}

Querying AD

The list of all unique departments and buildings (street addresses) assigned to user objects in AD is gathered in a fairly straightforward way:

  1. Get all enabled AD objects with the ObjectClass property of “User” with the “Department” and “StreetAddress” properties selected
  2. If a user object has a Department assigned and it is unique, add it to the list of departments
  3. If a user object has a Building assigned and it is unique, add it to the list of buildings

Again, I should have used System.Collections.ArrayList containers. Additionally, I notice that I took a concept from the Python 2 script that targets a specific AD OU to search beneath for users, but while the function requires the $SearchBase parameter, it doesn’t actually use it. Our goal was to have all international departments and buildings synced in the JSS, so while this script achieves that, this may not be the case for other companies. This doesn’t hinder the script, but it prevents users from targeting their user search in a specific OU if they so desire. I’ll need to implement this or remove the option altogether in a future pull request.

# Gathers lists of all unique departments and buildings that exist in LDAP user records.  
function GetLdapLists ([String]$SearchBase, [String]$LdapServer, [PSCredential]$Credential) {  
  # Return variables  
  $departments = @{}  
  $buildings = @{}

  $getADUserParams = @{  
    Filter = {(Enabled -eq $True) -and (ObjectClass -eq "User")};  
    Properties = "Department", "StreetAddress";  
    Server = $LdapServer;  
    Credential = $Credential;  
  } # $SearchBase is not actually used

  $staff = Get-ADUser @getADUserParams

  foreach ($user in $staff) {  
    try {  
      $department = $user.Department  
      if (!$departments.ContainsKey($department)) {  
        $departments.Add($department, $null)  
      }  
    }  
    catch {  
      continue  
    }

    try {  
      $building = $user.StreetAddress  
      if (!$buildings.ContainsKey($building)) {  
        $buildings.Add($building, $null)  
      }  
    }  
    catch {  
      continue  
    }  
  }

  $departments, $buildings  
}

Compare AD and JSS

In the last bit of data processing, the lists of departments and buildings in AD/JSS are compared against each other which determines which departments/buildings need to be created and deleted. This function is generalized to be used for both departments and buildings. The last case in which I should have used System.Collections.ArrayList containers.

# Returns:  
# A list of items that exist in LDAP but not JSS (to be created in JSS).  
# A list of items that exist in JSS but not LDAP (to be deleted from JSS).  
function CompareLists ([HashTable]$LdapList, [HashTable]$JssList) {  
  $toCreate = @{}  
  $toDelete = @{}

  foreach ($i in $LdapList.Keys) {  
    if (!$JssList.ContainsKey($i)) {  
      $toCreate.Add($i, $null)  
    }  
  }

  foreach ($i in $JssList.Keys) {  
    if (!$LdapList.ContainsKey($i)) {  
      $toDelete.Add($i, $null)  
    }  
  }

  $toCreate, $toDelete  
}

Execution

With all of the functions defined, execution can begin! Since most of the area below line 190 # Begin script execution are simply calls to the functions described above or variables that need to be filled in by the user, I’ll explore two areas of interest that have yet to be explained.

Optional Stored Credentials

The Python 2 script provided variables that are designed to store the username and password of the JSS and AD accounts that will be used to execute this script and read/write data from both directories. The PowerShell script prompts for these values by default, but I left a block of commented code that can be uncommented and used to store these credentials explicitly if desired.

# LDAP/JSS Credentials - assumed to be the same  
# Uncomment the below block and enter username/password for autorun  
<# $Username = ""  
$PasswordUnencrypted = ""  
$SecureStringParams = @{  
  String = $PasswordUnencrypted;  
  AsPlainText = $True;  
  Force = $True;  
}  
$Password = ConvertTo-SecureString @SecureStringParams # Enforcing SecureString type for password #>

Optional CSV Output

As an alternative to logging in a text file, a block of commented code at the end of the script can be uncommented to output four CSV files containing the lists of departments/buildings to be created/deleted.

# CSV Output - uncomment to output lists of departments/buildings created/deleted  
<# $JssCreateDepartments.GetEnumerator() | Export-Csv -Path ".\CreateDepartments.csv" -Delimiter ',' -NoTypeInformation  
$JssDeleteDepartments.GetEnumerator() | Export-Csv -Path ".\DeleteDepartments.csv" -Delimiter ',' -NoTypeInformation  
$JssCreateBuildings.GetEnumerator() | Export-Csv -Path ".\CreateBuildings.csv" -Delimiter ',' -NoTypeInformation  
$JssDeleteBuildings.GetEnumerator() | Export-Csv -Path ".\DeleteBuildings.csv" -Delimiter ',' -NoTypeInformation #>

Summary

Not considering my inefficient use of hash tables and unused $SearchBase parameter, this script is a successful modern approach to syncing the departments and buildings in a JSS with AD. The two described oversights will be remedied and a pull request will be submitted at a later date. I’m thankful to the JAMFIT team for merging my conversion of their Python 2 script!

Updated:

Comments