I was chatting with my friend Gladys Kravitz recently about some PowerShell scripting she was doing with Active Directory and the DirectorySearcher object. For a number of reasons, the Active Directory module from Remote Server Administration Tools (RSAT) is not an option. But that's fine. Gladys is an experience AD admin and PowerShell scripter. Still, she had a question about the output from her commands, which led to some interesting work that you might find valuable. Even though I'm going to be discussing a specific object type, there are several principles that you can apply to your PowerShell scripting.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
Search Result Properties
Let's begin with some PowerShell code to find a user account using the DirectorySearcher object.
$searcher = New-Object System.DirectoryServices.DirectorySearcher
$searcher.filter ="(&(objectcategory=person)(objectclass=user))"
$searcher.FindOne().Properties
The DirectorySearcher has methods of FindOne() and FindAll(). The resulting object has a Properties property.
You can use this in any number of ways.
New-Object -typename PSObject -property $searcher.FindOne().Properties | Select Name,Description,WhenChanged
Leaving the formatting of the values aside, notice the property names. Functional, but not professional-looking. This is always an option.
New-Object -typename PSObject -property $searcher.FindOne().Properties |
Select-Object @{Name="Name";Expression = {$_.name}},
@{Name="Description";Expression = {$_.Description}},
@{Name="WhenChanged";Expression = {$_.WhenChanged}}
Or you can define a custom object with a type name and use a custom format file. But that's a lot of work and not flexible.
Here's another approach to creating an object but with properly cased property names.
$r = $searcher.FindOne()
$r.Properties.GetEnumerator() |
Where-Object { $_.key -match "Name|Description|WhenChanged|memberof|logoncount" } |
ForEach-Object -Begin {
#initialize an empty hashtable
$h = @{}
} -Process {
#convert to title case
$n = [cultureinfo]::CurrentCulture.TextInfo.ToTitleCase($_.name.tolower())
if ($_.value.count -gt 1) {
#get the array of values
$v = $_.value
}
else {
#get the single value
$v = $_.value[0]
}
#add the property name and value to the hashtable
$h.Add($n, $v)
} -End {
#create a custom object from the hashtable
[pscustomobject]$h
}
In this particular situation, the object I am formatting is a DirectoryServices.ResultCollection, which is, in essence, a hashtable, so it requires a different approach. The GetEnumerator() method writes each element in the collection as an object with Key and Value properties. My example is filtering keys for specific "properties."
The filtered objects are then piped to ForEach-Object. Before any objects are processed in the pipeline, I initialize an empty hashtable. Next, for each filtered result, I convert the name, which is an alias for 'key,' to title case. For this to work, I always make the string all lower case before invoking the method. This reformatted name and the corresponding value are added to the hashtable. After everything has been processed, I turn the hashtable into a custom object.
This is promising but needs to be more flexible. The ForEach-Object structure is the same format as an advanced PowerShell function that takes pipeline input. In other words, I have a prototype.
Creating Configuration Data
My idea is to take a DirectorySearcher result and create a custom object replacing the property name with a properly formatted name and the value. But in looking at the output, I want more than title case. 'Logoncount' might look nicer formatted as 'LogonCount.' I prefer SamAccountName. I want to replace the incoming property name with a properly formatted name. I will need a list of all possible property names formatted the way I want. Because I know I will eventually want to use this list as a hashtable, I'll create a configuration data file that I can use later with Import-PowerShellDataFile.
Here's the PowerShell script to generate different configuration data files depending on the object type.
#requires -version 5.1
<# Create-ADPropertyFile.ps1
proof-of-concept code to create an Active Directory property configuration data file
.\Create-ADPropertyFile.ps1 -Filter "(&(objectclass=organizationalunit)(Name=Employees))" -FilePath .\adou.psd1
.\Create-ADPropertyFile.ps1 Group -FilePath .\adgroup.psd1
#>
[cmdletbinding(SupportsShouldProcess, DefaultParameterSetName = "byType")]
Param(
[Parameter(Position = 0, ParameterSetName = "byType")]
[ValidateSet("User", "Group", "Computer", "OU")]
[string]$ObjectType = "User",
[Parameter(HelpMessage = "Specify an LDAP search filter to a template object.", ParameterSetName = "byFilter")]
[ValidateNotNullOrEmpty()]
[string]$Filter,
[Parameter(Mandatory, HelpMessage = "Specify the filename and path to the psd1 file.")]
[ValidateScript({ Test-Path (Split-Path $_) })]
[string]$FilePath
)
if ($pscmdlet.ParameterSetName -eq "byType") {
Switch ($ObjectType) {
"User" { $filter = "(&(objectcategory=person)(objectclass=user))" }
"Group" { $filter = "(Objectclass=group)" }
"Computer" { $filter = "(objectclass=computer)" }
"OU" { $filter = "(objectclass=organizationalunit)" }
}
}
$searcher = New-Object System.DirectoryServices.DirectorySearcher
$searcher.filter = $filter
#don't need values from the search
$searcher.PropertyNamesOnly = $true
$searcher.FindOne().Properties.GetEnumerator() | Sort-Object -Property Key |
ForEach-Object -Begin {
#initialize a list
$list = [System.Collections.Generic.List[string]]::new()
$list.add("@{")
} -Process {
$value = [cultureinfo]::CurrentCulture.TextInfo.ToTitleCase($($_.key.ToLower()))
#add each property to the list
$List.Add("$($_.key) = '$value'")
} -End {
#close the psd1
$list.Add("}")
}
#save the list to a file
$list | Out-File -FilePath $FilePath
#edit psd1 file with your desired formatting
The default filters will find the first matching result. However, the DirectorySearcher will only return property names that have been defined. You might want to create a template object with all the properties defined that you intend to use and then build the property file using a custom LDAP filter to that object.
I'll use the script to create a psd1 file for user objects.
c:\scripts\Create-ADPropertyFile.ps1 -ObjectType User -FilePath c:\scripts\aduser.psd1
I can edit the file and adjust the property names.
@{
accountexpires = 'AccountExpires'
admincount = 'AdminCount'
adspath = 'AdsPath'
badpasswordtime = 'BadPasswordTime'
badpwdcount = 'BadPwdCount'
cn = 'CN'
codepage = 'CodePage'
countrycode = 'CountryCode'
department = "Department"
description = 'Description'
displayname = "DisplayName"
distinguishedname = 'DistinguishedName'
dscorepropagationdata = 'DscorePropagationData'
givenname = "GivenName"
instancetype = 'InstanceType'
iscriticalsystemobject = 'IsCriticalSystemObject'
lastlogoff = 'LastLogoff'
lastlogon = 'Lastlogon'
lastlogontimestamp = 'LastLogonTimestamp'
logoncount = 'LogonCount'
logonhours = 'LogonHours'
memberof = 'MemberOf'
name = 'Name'
objectcategory = 'ObjectCategory'
objectclass = 'ObjectClass'
objectguid = 'ObjectGuid'
objectsid = 'ObjectSid'
primarygroupid = 'PrimaryGroupId'
pwdlastset = 'PwdlastSet'
samaccountname = 'SamAccountName'
samaccounttype = 'SamAccountType'
sn = "Surname"
title = "Title"
useraccountcontrol = 'UserAccountcontrol'
usnchanged = 'UsnChanged'
usncreated = 'UsnCreated'
whenchanged = 'WhenChanged'
whencreated = 'WhenCreated'
}
}
If you know additional LDAP property names, you could manually add them. The last step is to use this file.
Optimize AD Search Result
We tend to avoid monolithic commands in PowerShell. Instead, we want to leverage the pipeline. I have code using the DirectorySearcher to find objects.
I want to take these results and optimize the property names.
Function Optimize-ADSearchResult {
[cmdletbinding()]
Param(
[Parameter(
Position = 0,
Mandatory,
ValueFromPipeline,
HelpMessage = "This should be the input from an ADSearcher FindOne() or FindAll() method."
)]
[System.DirectoryServices.SearchResult]$InputObject,
[Parameter(
Mandatory,
HelpMessage = "Enter the path to the psd1 file with your property names. See Create-ADPropertyFile."
)]
[ValidateNotNullOrEmpty()]
[ValidateScript({ Test-Path $_ })]
[ValidatePattern('\.psd1$')]
[string]$ConfigurationData,
[Parameter(HelpMessage = "Specify a custom type name, like CorpADUser. You might add this if using a custom format file or type extensions.")]
[ValidateNotNullOrEmpty()]
[string]$TypeName
)
Begin {
Write-Verbose "[$((Get-Date).TimeofDay) BEGIN ] Starting $($myinvocation.mycommand)"
#import the configuration data
Write-Verbose "[$((Get-Date).TimeofDay) BEGIN ] Importing configuration data from $(Convert-Path $ConfigurationData)"
$PropertyData = Import-PowerShellDataFile -Path $ConfigurationData
} #begin
Process {
Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Processing input"
$InputObject.properties.GetEnumerator() |
ForEach-Object -Begin {
$new = [ordered]@{ }
if ($TypeName) {
Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Using typename $TypeName"
$new.Add("PSTypename", $TypeName)
}
} -Process {
Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Processing property $($_.key)"
#Get formatted property name from configuration data
if ($PropertyData.Contains($_.key)) {
$name = $PropertyData["$($_.key)"]
}
else {
$name = $_.key
}
if ($_.value.count -gt 1) {
$value = $_.value
}
else {
$value = $_.value[0]
}
$new.Add($name, $value)
} -End {
Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Creating output"
New-Object -TypeName psobject -Property $new
}
} #process
End {
Write-Verbose "[$((Get-Date).TimeofDay) END ] Ending $($myinvocation.mycommand)"
} #end
}
The function looks at each search result and tries to match the property name from the configuration data. If it finds a match, it will be used. Otherwise, PowerShell will use the original property name.
I could have stored all of the property name hashtables in the function, but that would have added complexity and unnecessary length. If I need to add a property, I have to edit the function. It is much better to separate data from the code that implements it. My function is a good example of using configuration data.
I can take the search results, optimize them with this function, and then select what I need.
$searcher = New-Object System.DirectoryServices.DirectorySearcher
$searcher.filter = "(&(objectcategory=person)(objectclass=user)(department=sales))"
$searcher.FindAll() |
Optimize-ADSearchResult -ConfigurationData C:\scripts\aduser.psd1 |
Select-Object -property DistinguishedName,Givenname,Surname,Description,Title
Summary
There is a lot going on in this post—many moving parts and possibly some new commands. I hope you'll review the code and at least walk through it in your head. Please feel free to leave comments and questions.
1 thought on “Better PowerShell Properties”
Comments are closed.