A few days ago, I posted a PowerShell script I wrote that creates a formatted HTML report, complete with collapsible regions, which shows recent changes to objects in your Active Directory domain. Including objects that have been deleted, assuming you enabled the Active Directory RecycleBin feature. I am pleased with the result and many of you found it useful and at least worth looking at. However, I realize now that I went about the process backward. True, the HTML generating script is based on PowerShell code that I can run interactively to show Active Directory changes. But I should have gone further. I should have built a PowerShell tool that would show me the same changes, but from a PowerShell console prompt. I hinted at this in the original article. The script I wrote is useful, but it can only do what it is designed to do -- create an HTML report. Here's how I fixed my oversight.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
Start with a PowerShell Cmdlet
As with any PowerShell toolmaking project you want to start with a cmdlet and a PowerShell expression or set of expressions, that you can run interactively to achieve the desired result. Or at least enough of the result that you can embellish. I had that code using Get-ADObject from the previous script. But where the HTML report script could only do one thing, I wanted my command-line tool to be more flexible.
In addition to supporting some of the Get-ADObject parameters such as Credential, Server and SearchBase, I also wanted the user to be able to specify the type of object. Maybe I only need to see Group objects now, but tomorrow I want to see users and computers. I will need a parameter like this:
[Parameter(HelpMessage = "Specify the types of objects to query.")]
[ValidateSet("User","Group","Computer","OU")]
[ValidateNotNullOrEmpty()]
[string[]]$Category = "User",
There are many types of Active Directory objects but these are the ones that I want to watch on a regular basis. Notice the use of [ValidateSet()]. These are the only possible values and PowerShell will tab-complete them. Remember that if you specify a default value that it is included in the validation set.
I also thought about what properties I wanted to see. You aren't limited to original object. I had a list of what I wanted from the Get-ADObject command. But I also wanted to include the name of the domain controller. You might get different results depending on domain controller replication so I wanted to capture the domain controller. I couldn't find anywhere in the output that showed that to me, so I decided to set a default parameter value for the server so that I'd always have something to use.
[Parameter(HelpMessage = "Specifies the Active Directory Domain Services domain controller to query. The default is your Logon server.")]
[alias("DC")]
[ValidateNotNullorEmpty()]
[string]$Server = $env:LOGONSERVER.SubString(2)
The environment variable uses a format like \\DOM1 but Get-ADObject is expecting a server value of DOM1 so my default value parses the environment value. I could have made the parameter mandatory, but that felt like overkill. Although I could have added an ArgumentCompleter to the Server parameter.
[ArgumentCompleter({(Get-ADDomain).ReplicaDirectoryServers})]
In a small domain, this might be a good choice.
LDAP Queries
My HTML reporting script used a single filter to get all objects modified since a given datetime. But my new PowerShell tool was expecting to be a bit more granular. I started out trying to build a complex filtering expression for Get-ADObject on-the-fly, based on parameter values from my function. This soon got out of hand. In addition, there was a quirk in Active Directory that I had forgotten about. My early versions returned changed objects for users AND computers, even when I was only searching for users. This is because the computer object is derived from the user object and searching for the latter included the former.
My solution was to fall back to LDAP filters which turned out to be a good thing. Now I could have a filter that returned exactly the object type I was looking for. However, including filtering on the datetime value added another wrinkle. I couldn't include something like "WhenChanged >= '1/28/2021 2:00PM'". I needed to convert the datetime value to a string formatted as yyyyMMddhhmmss.ff followed by a timezone offset like -0400. I wrote a private helper function to reformat the datetime value.
Function _ConvertToLDAPTime {
#a private helper function to convert a date time object into a LDAP query-compatible value
Param([datetime]$Date)
$offset = (Get-TimeZone).baseUtcOffset
#values must be formatted with leading zeros to the specified number of decimal places
$tz = "{0:d2}{1:d2}" -f $offset.hours,$offset.Minutes
"{0:yyyyMMddhhmmss}.0{1}" -f $date,$tz
}
I'm using a non-standard name because this function is not exposed to the user and is only called within the parent function.
$dt = _ConvertToLDAPTime -Date $Since
In my function, I will loop through each object class using an LDAP filter.
foreach ($objClass in $Category) {
Switch ($objclass) {
"User" { $ldap = "(&(WhenChanged>=$dt)(objectclass=user)(!(objectclass=computer)))" }
"Computer" { $ldap = "(&(WhenChanged>=$dt)(objectclass=computer))"}
"Group" { $ldap = "(&(WhenChanged>=$dt)(objectclass=group))"}
"OU" { $ldap = "(&(WhenChanged>=$dt)(objectclass=organizationalunit))"}
}
Write-Verbose "[$(Get-Date)] Using LDAP filter $ldap"
$getparams["LDAPFilter"] = $ldap
Get-ADObject @getParams | Foreach-Object { $items.Add($_)}
}
Creating New Objects
The results are saved to a list object which is then processed and turned into a new custom object with a typename of ADChange.
foreach ($item in $items) {
if ($item.WhenCreated -ge $since) {
$isNew = $True
}
else {
$isNew = $false
}
#create a custom object based on each search result
[PSCustomObject]@{
PSTypeName = "ADChange"
ObjectClass = $item.ObjectClass
ObjectGuid = $item.ObjectGuid
DistinguishedName = $item.DistinguishedName
Name = $item.Name
DisplayName = $item.DisplayName
Description = $item.Description
WhenCreated = $item.WhenCreated
WhenChanged = $item.WhenChanged
IsNew = $IsNew
IsDeleted = $item.Deleted
Container = $item.distinguishedname.split(",", 2)[1]
DomainController = $Server.toUpper()
ReportDate = $ReportDate
}
} #foreach item
Why go to this effort? So that I can customize the object to make it easy to use in the pipeline. For example, I can define a few alias properties as well as a set of default properties.
Update-TypeData -TypeName ADChange -DefaultDisplayPropertySet DistinguishedName,WhenCreated,WhenChanged,IsNew,IsDeleted,ObjectClass,ReportDate -Force
#define some alias properties for the custom object
Update-TypeData -TypeName ADChange -MemberType AliasProperty -MemberName class -Value ObjectClass -Force
Update-TypeData -TypeName ADChange -MemberType AliasProperty -MemberName DN -Value DistinguishedName -Force
I can also create a custom format file so not only can I get the default formatted view that I want, but I can include other views as well.
Update-FormatData $PSScriptRoot\ADchange.format.ps1xml
I use Verbose output all the time in my PowerShell work. In this function I'm including what I refer to as runtime metadata.
The idea is that if someone has a problem running the command, I can have them start a transcript, run the command with -Verbose, stop the transcript and send it me. Hopefully the verbose output can help me isolate the problem.
Get the Code
By now some of you are looking for the rest of the code. Because this is something I might keep tinkering with, I have posted the Get-ADChange function and the format file on Github. Go to https://gist.github.com/jdhitsolutions/3bce157bd64717dd616b949f6e280433 and grab both files. The files should go in the same folder and make sure the format file is saved as adchange.format.ps1xml . You can then dot source the script file:
. c:\scripts\get-adchange.ps1
Obviously use the appropriate path. The script will also load the format file into your PowerShell session. You should be able to run the Get-ADChange command from a Windows 10 desktop that has the ActiveDirectory module installed.
That said, this code is offered as-is with no guarantees or support. I strongly encourage you to test thoroughly in a non-production environment.
Summary
I could go back to my HTML reporting script and modify it to use this new function. Or I might build other controller scripts around it. And let me re-iterate that if you a larger Active Directory infrastructure, you will be better served by investing in true management and reporting solutions. A number of readers wanted to be able to discover who might have made a change. That information is not stored with the Active Directory object. That's where auditing and logging come into play and where a "real" management product is worth the investment.
Still, I hope you learn something about the process of building a PowerShell tool from this. As always, questions and comments are welcome.
1 thought on “Building a PowerShell Tool for Active Directory Changes”
Comments are closed.