Category Archives: Active Directory

Getting stats on AD Groups

Sup’ PSHomies,

Did you ever had the need to “know” just who are members of a specific group and more importantly, if they were users or groups… Maybe I can assist… 😉

To distinguish between a user or group use Get-ADUser / Get-ADGroup. Get-ADUser will process None or Microsoft.ActiveDirectory.Management.ADUser only objects. The same goes for Get-ADGroup. Either None or Microsoft.ActiveDirectory.Management.ADGroup only objects will be processed.

With this info we can now process the AD Group to get just who is a member and what is their object class.

#region Get AD Group stats
#Specify ADGroup(s) using like
$groupName = 'DAT_ICT*'
$adGroup = Get-ADGroup -Filter { Name -like $groupName} |
Foreach-Object {
[PSCustomObject]@{
Group = $_
GroupMembers = Get-ADGroup -Filter { memberOf -eq $_.DistinguishedName }
UserMembers = Get-ADUser -Filter { memberOf -eq $_.DistinguishedName }
UserMembersRecursive = Get-ADGroup -Filter { memberOf -eq $_.DistinguishedName } |
ForEach-Object{
Get-ADGroupMember -Identity $_ -Recursive
}
GroupMemberOf = Get-ADGroup -Filter { members -eq $_.DistinguishedName }
}
}
#Get Count of the ADGroup(s)
$adGroupMembersCount = $adGroup |
ForEach-Object{
[PSCustomObject]@{
Group = $_.Group.Name
countGroupMembers = @($_.GroupMembers).Count
countUserMembers = @($_.UserMembers).Count
countUserMembersRecursiveUnique = @($_.UserMembersRecursive | Select-Object -Unique ).Count
countGroupMembersOf = @($_.GroupMemberOf).Count
}
}
#endregion

I opted for a specific AD Group name pattern. Feel free to refactor to your needs! Depending on your AD size it may take a while if you decide to retrieve all AD Groups…

Hope it’s worth something to you,

Ttyl,

Urv

Taking ADUser validation a step further…

‘Sup PSHomies,

This is me every single time I sit down to use Pester…

PesterOVF.PNG

Hehe…

While I do enjoy using Pester for operational validation, what do you do with the ones that fail? Most of the time you’re the one doing the validation in the chain of process and everything goes right! Because you’re that good… 😉 Hehe…  I’ve been recently asked to update some users where some attributes didn’t get populated during a migration… Are you thinking what I’m thinking? 😉

The first thing I did was export the attributes they needed, easy enough. Next was to change the necessary attributes to the correct value. The ones that differ will fail (Of course they will Urv, not a total retard here… get on with it… )

Here’s where having proper descriptions can go a long way.

Breadcrumbing

This is what a Test ErrorRecord looks like when you capture the results:PesterError

At first I thought of using regex on the ErrorRecord. Some of the  attributes aren’t set which gave me some issues, so I decided to breadcrumb the name. First do a split on the colon ‘:’ grab the last part and do a split again using ‘/’ to get the attribute name and value. Don’t forget to trim() 🙂

There were some other Attributes that weren’t part of the default parameter set of the Set-ADUser cmdlet. To change those attributes you need to use the DisplayName and the Replace Operator. For the attributes “not set” that need to be cleared, use the Clear operator. Just don’t use both the parameter and the DisplayName! I had EmailAddress and mail in the CSV file, one passed and the other failed… I got rid of mail…

Ok here’s the code to get things done:

First get failed Pester tests.

<#
Author: I. Strachan
Version:
Version History:
Purpose: Validate AD User attributes
#>
[CmdletBinding(SupportsShouldProcess = $True)]
Param(
$csvFile = 'd:\scripts\ps1\source\csv\SetUser.csv'
)
#region Import Csv file
$csvUsers = Import-Csv -Path $csvFile -Delimiter "`t" -Encoding UTF8
$userProperties = ($csvUsers | Get-Member -MemberType NoteProperty).Name |
Where-Object {$_ -ne 'OU'}
#endregion
#region Main
$csvUsers |
Foreach-Object {
$Expected = $_
Describe "Processing User: $($Expected.SamAccountName)" {
Context "Verifying AD User properties for $($Expected.DisplayName)" {
#Get AD user properties
$Actual = Get-ADUser -Identity $Expected.SamAccountName -Properties $userProperties
ForEach( $property in $userProperties){
if (([string]::isNullOrEmpty($Expected.$property))) {
$Expected.$property = $null
$lableExpected = '<not set>'
}
else{
$lableExpected = $Expected.$property
}
it "Verifying user property: $($property) / $($lableExpected)"{
$Actual.$property | Should be $Expected.$property
}
}
}
}
}
#endregion
#Run PesterTest and save results
$resultsTest = Invoke-Pester D:\scripts\ps1\dsa\ADUser.Properties.Tests.ps1 -PassThru
#Get All failed tests
$failedTests = $resultsTest.TestResult.where{$_.Passed -eq $false}
$failedTests |
ForEach-Object{
$result = $_.Name.Split(':')[-1]
$arrResult = $result.Split('/')
[PSCustomObject]@{
SamAccountName = ($_.Describe.split(':').Trim())[-1]
Property = $arrResult[0].Trim()
Expected = $arrResult[1].Trim()
}
} -OutVariable failedObjects
#Export Failed objects
$failedObjects |
Export-Csv -Path D:\Scripts\ps1\source\csv\FailedTests.csv -Delimiter "`t" -NoTypeInformation -Encoding UTF8

This will give me a csv file with the following Columns, SamAccountName, Property & Expected (Value).

The set failed Pester tests will either set or clear the attribute depending on it’s value. If it’s $null it will be cleared.

<#
Author: I. Strachan
Version:
Version History:
Purpose: Set ADUser attributes of failed tests
#>
[CmdletBinding(SupportsShouldProcess = $True)]
Param(
$csvFile = 'D:\Scripts\ps1\source\csv\FailedTests.csv'
)
$FailedTests = Import-Csv -Path $csvFile -Delimiter "`t" -Encoding UTF8
#Get Set-ADUser Parameters
$setADUserParameters = (Get-Command Set-ADUser).ParameterSets.Parameters.Where{ $_.IsDynamic -eq $true} | Select-Object -ExpandProperty Name
#Get User Property
$FailedTests |
Foreach-object{
#Set Expected to null if <not set>
$Expected = @{$true = $null; $false = $_.Expected}[$_.Expected -eq '<not set>']
If ($setADUserParameters.Contains($_.Property)){
$paramSetADUser = @{
Identity = $_.SamAccountName
$_.Property = $Expected
}
Set-ADUser @paramSetADUser
}
else{
if($Expected){
Set-ADUser -Identity $($_.SamAccountName) -Replace @{$_.Property = $Expected}
}
else{
Set-ADUser -Identity $($_.SamAccountName) -Clear $($_.Property)
}
}
}

Here are some screenshots to give you an idea what to expect.

Ideally you’d only have a few failed tests. I wouldn’t use this to reset entire User attributes. Moving and/or renaming an object isn’t supported… yet! 😉

So there you have it, taking failed Pester tests values and setting them accordingly!

Hope it’s worth something to you,

Ttyl,

Urv

 

 

The lowdown on SIDHistory

Sup’ PSHomies,

SIDHistory is one of those Active Directory attributes you love to hate. When migrating from one domain to another, it let’s you retain access to resources in the Source Domain. This is a great way to transition, but in my experience it also makes for quick-shift migrations.

The first thing I do whenever we start a migration is have a look at SIDHistory. This will let me know quickly what we’re dealing with:

  • Has there been a previous migration? (I’ve seen objects in excess of 5 entries)
  • Did they clean up? (Obviously they didn’t or I wouldn’t see any entries)
  • Do I need to worry about Token-bloat?

Remember the blog I did about SDDL? Well SDDL deals with access based on SIDs. When a user logs on to the system, not only the new SID, but also the old SID is retrieved from the SIDHistory attribute and is added to the user’s access token and used to determine the user’s group memberships. The SIDs of the groups of which the user is a member through either the new SID or the old SID are then also added to the access token, together with any SIDHistory those groups might have.

This is also the reason tokenbloat can be an issue if it isn’t cleaned up after a migration.

So how do you find out about SIDHistory?

Get-ADObject -LDAPFilter "(sIDHistory=*)" -Property objectClass, distinguishedname, samAccountName, objectSID, sIDHistory |
Select-Object objectClass, DistinguishedName, SamAccountName, objectSID -ExpandProperty SIDHistory |
ForEach-Object {
[PSCustomObject]@{
ObjectClass = $_.objectClass
DistinguishedName = $_.DistinguishedName
SamAccountName = $_.SamAccountName
SID = $_.ObjectSID
DomainSID = $_.AccountDomainSID
SIDHistory = $_.Value
}
} |
Out-GridView -Title "AD Objects with SIDHistory"

On the subject of removing SIDHistory

This is tricky. Having and keeping SIDHistory intact will keep many a pesky helpdesk calls at bay… But is it wise to keep it?

From a Data (Read NTFS) perspective, you’ll need to Re-Acl your data structure. If you’ve kept you NTFS ACLs (Access Control List) nice and tidy (Wait, gimme a second to catch my breath from laughing) then you’re golden! This has never been the case in all my migrations  so far. My advice when it comes to Re-Acl, is to recreate the data structure (empty) and assign the correct ACEs (Access Control Entry) to the ACLs. Maybe I need to explain what Re-acl a bit more…

Re-ACL is the process of translating SIDs on Resources. I first came across the term using Quest Migration tools. This gave me the option to:

  • add the target SID to a resource
  • replace source SID on said resource
  • remove source SID from resource if everything is working

Here are the things you need to consider for each option

Adding the targetSID to a Resource

This gives the AD Object  access without having to rely on SIDHistory. This means that once the target SID has been added you can safely clean up SIDHistory. A target SID can only be added if a valid source SID has been found. I’ve seen too many ACLs with unknown ACEs in migrations I did over the years. This does nothing to clean up those unknown ACEs. Adding a target SID will expand you ACLs, which can have an impact on processing time

Replacing the sourceSID on a Resource

This makes for a cleaner ACL. Again, this does nothing for unknown ACEs. Replacing adds the targetSID and removes the sourceSID in the same process. A bold move, reverting SIDHistory isn’t as easy a writing to other AD object attributes and for good reason.

Remove sourceSID from Resource once everything has been verified to be working

Most are quite content that everything is working and don’t bother with this. Again, if your structure is up to date, this shouldn’t be an issue. What I’ll usually hear is: “We’ll create a new structure later on and get that cleaned up…” This rarely happens…

To wrap up

SIDHistory is a great way to retain access to source Resources, just make cleanup a part of the migration (If possible).This will vastly improve tokensize and improve your security

Re-Acl only makes sense if you’re content with your current NTFS data structure. If not, then I’d suggest redefining your Data structure. It’s a chore but well worth it.

Hope it’s worth something to you…

Ttyl,

Urv

Venturing into the world of PowerShell & DataTables

‘Sup PSHomies,

Been rethinking my Active Directory snapshot approach…

There are many ways to Rome, so which one to you choose? I’ve always been a fan of CSV and the Export-Csv cmdlet. Then I found out that Export-CliXML makes for a better experience when it comes to saving PS Objects (just don’t try figuring the tags out). Use the Import-CliXML cmdlet and you’ve got your PS Objects with Types back and in tact! Kevin Marguette has a great blog about this. While you’re at it, Jeff Hicks also has a great series on this topic, sharing is caring… 🙂

Gathering all you Active Directory data in one PS Object might not be the best approach when it comes to performance on a sizable AD. In my test lab, that wasn’t an issue. When I ran my script against a sizable AD, let’s just say I ran into some memory issues… 😉

My objective is to:

  • Gather AD data for offline reporting  purposes.
  • Keep data fragmentation to a minimum.

Let’s gather ADUser data for this exercise…

I want to collect the following data on AD users:

  • Disabled
  • Expired
  • NoExperationDate
  • Inactive
  • MustChangePassword
  • CannotChangepassword
  • All User & Properties

Approach #1

Pretty straightforward. Use the pipeline to keep resource usage to a minimum. When processing large collections you should always think pipeline!

Get-ADUser -Filter * -Properties * |
Export-Clixml .\export\dsa\dsADUsers-CliXML.xml -Encoding UTF8

Exporting directly using Export-CliXML will get the job done. I could do this for each AD User query:

  • Search-ADAccount -AccountDisabled
  • Search-ADAccount -AccountExpired
  • Get-ADUser -LDAPFilter ‘(|(accountExpires=0)(accountExpires=9223372036854775807))’
  • Search-ADAccount -AccountInactive
  • Get-ADUser -Filter {pwdLastSet -eq 0}
  • Get-ADUser -Filter * -Properties CannotChangePassword |Where-Object {$_.CannotChangePassword}
  • Get-ADUser -Filter * -Properties *

Nothing wrong with that, other than having a bunch of CliXML files. Collecting everything first isn’t an option performance wise.

Approach #2

Use a data Set/Table.

Full disclosure: This is my first attempt using data set/tables. I’m pretty sure that there’s a better approach and I’d love to here all about it!!!

I (re)found Chrissy LeMaire blog on basic .NET Datasets. Now why doesn’t that surprise me? 😛 Hehe…

$dsADUsers = New-Object System.Data.DataSet
$dtADUsers = New-Object System.Data.Datatable 'ADUsers'
$columnsADUsers = Get-ADUser -Identity guest -Properties * |
Get-Member -MemberType Property |
Select-Object -ExpandProperty Name
$columnsADUsers |
Foreach-Object{
[void]$dtADUsers.Columns.Add("$($_)")
}
Get-ADUser -Filter * -Properties * |
Foreach-Object{
$Row = $dtADUsers.NewRow()
foreach ($column in $columnsADUsers){
$Row["$($column)"] = if($_.$column -ne $null){($_ | Select-Object -ExpandProperty $column) -join "`n"}else{$null}
}
$dtADUsers.Rows.Add($Row)
}
$dsADUsers.Tables.Add($dtADUsers)
$dtADUsers.WriteXml('c:\scripts\export\dsa\dtADUsers.xml')

 

Quick breakdown: First I created a Dataset (prefix ds just in case you were wondering…) and the data table. I wanted all available properties. I realize this might be more than you need so feel free to change that to your needs. To get the necessary column Names I’m using the Get-Member cmdlet with parameter MemberType Property. A quick select and expand and I have column names, beats hard coding column names…

Ok here’s where it gets a bit tricky, So I’m processing each object as it goes through the pipeline, but I do have a datatable object… Like I said first attempt…

Once the data table is ready just add it to the dataset! A dataset can hold multiple datatables which helps in solving my data gathering fragmentation.

Fun fact

Did you know that you can write/export your datatable to XML? Unlike CliXML output this one is legible…

dataTable ADuser

To write the dataTable to XML:

$dtADUsers.WriteXml('c:\scripts\export\dsa\dtADUsers.xml')

Same command applies for the dataSet 🙂 Added bonus is the file size, much smaller than CliXML, but then again not as rich…

dataTable ADuser size

Take away

I love the fact that there’s more than one way to work with data. I guess it comes down to preference. I was pleasantly surprised by the datatable XML formatting, nice and clean! If  export file size is an issue, then datasets can help, the trade off being you’ll loose rich data of the PS objects. The *CliXML cmdlets are sufficient for my needs, if I’m honest with myself, still glad I looked into data sets/tables…

Now If you have a full fledged SQL environment you can take advantage of, then, Go for it!!! Just ask Rob Sewell aka sqldbawithbeard  or Chrissy LeMaire, our PowerShell SQL experts to point you in the right direction!

The more you know! 😉

Hope it’s worth something to you…

Ttyl,

Urv

AD Operation Validation class

‘Sup PSHomies,

I’m like a dog with a bone… 😛

2016 was all about operation validation for me. I did a series on Active Directory snapshot, report and validation that was well received by the community! Classes will definitely make the user experience more pleasant! I decided to refactor the code to a class 😉 Got a lot of ground to cover so let’s dive in!

Here’s a screenshot of the ADInfrastructure (was what popped in my mind at the time) class properties and methods:

classadinfra

The focus will be on Active Directory’s forest, domain, sites, sitelinks, subnets and domaincontrollers.

These are the methods I’ve worked out so far (work in progress):

GetCurrentConfig() will populate the necessary properties using the right activedirectory cmdletsgetcurrentconfig

ImportADSnapshot() will import a saved XML file to ADSnapshot property.importadsnapshot

ExportADSnapshot() saves the class object as a XML file. This will generate a new XML file using the current $exportDate valueexportadsnapshot

RunValidation() deserves it own section… 😉

RunValidation()

This is where the the operation validation will take place. This was a bit of a challenge getting the method right, but I think it worked out just fine… I should explain…

In my first attempt I ran the validation directly from the method. You can invoke the Describe block just like a function. That wasn’t the challenge, have a look at the It blockitblock

The test is hard coded to use $this as source and $this.ADSnapshot as target. I blogged about some possible validation gotchas a while back. To remedy this, I decided to use a scriptblock. You can also provide parameters to a scriptblock . In this case I provided ($Source, $Target) as parameters. This will make interchanging  input easier of which I’ll explain the advantages later on.

desribesourcetarget

Ok so the scriptblock was a good idea. One of the things I wanted to do was save the validation results. That’s where I ran into something interesting. The input has to be a *.tests.ps1 file(s). I tried using the scriptblock as input but that didn’t work. I visited the github page to see if scriptblock is supported as a feature, it isn’t. In order to save the results I would first have to save the test to a file. The scriptblock made that part a lot easier. As a workaround, this isn’t an issue.The file is generate every time RunValidation is executed, an inconvenience at most.  A scriptblock feature  would make for a cleaner approach.

Quick side step: There’s a poll on twitter for anyone interested in casting a vote 🙂 Only 5 more days left…

pester-poll

The test results are saved in ValidationResults. That’s RunValidation in a nutshell.

It’s quite a bit of code, so I’ll post that at the end of the blog. Here’s what you can expect if you try it out. First up, a simple verification of the current configuration against a saved snapshot

#region Verify Current Configuration against a snapshot
$snapShotDate = '12012017'
$ADInfra = [ADInfrastructure]::New()
$ADInfra.GetCurrentConfig()
$ADInfra.snapshotDate = $snapShotDate
$ADInfra.ExportADSnapshot()
$ADInfra.ImportADSnapshot()
$ADInfra.ADSnapshot
$ADInfra.RunValidation($ADInfra,$ADInfra.ADSnapshot,@('Forest','Domain'))
$ADInfra.RunValidation($ADInfra.ADSnapshot,$ADInfra,@('Forest','Domain'))
$ADInfra.ActionHistory | Select-Object -Property TimeGenerated,Tags,MessageData
#endregion

Before you get startetd, you need to instantiate the class. GetCurrentConfig() will save the information to the properties. ExportADSnapshot() will create a ADSnapshot-($exportDate).xml file. ImportADSnapshot will import any existing snapshot file of a given $snapshotDate formatted as ‘ddMMyyyy’.

Because I’m verifying the current configuration with a snapshot without any changes all the tests will pass.

currentconfigtest

No surprises.

For the next example, I wanted to validate against a manual configuration. This is where the scriptblock really made a difference. I added a non-existent DC to  $ADVerifyConfig for testing purposes.

 

#region verify a manual Configuration a against a snapshot
$primaryDC = 'DC-DSC-01.pshirwin.local'
$ADVerifyConfig = @{
Forest = @{
Name = 'pshirwin.local'
ForestMode = 'Windows2012R2Forest'
DomainNamingMaster = $primaryDC
SchemaMaster = $primaryDC
GlobalCatalogs = @(
$primaryDC
)
}
Domain = @{
DistinguishedName = 'DC=pshirwin,DC=local'
InfrastructureMaster = $primaryDC
PDCEmulator = $primaryDC
RIDMaster = $primaryDC
}
Sites = @(
[PSCustomObject]@{
Name = 'Default-First-Site-Name'
}
[PSCustomObject]@{
Name = 'Branch01'
}
)
SiteLinks = @(
[PSCustomObject]@{
Name = 'DEFAULTIPSITELINK'
Cost = 100
ReplicationFrequencyInMinutes = 180
}
)
Subnets = @(
[PSCustomObject]@{
Name = '192.168.0.0/24'
Site = 'CN=Branch01,CN=Sites,CN=Configuration,DC=pshirwin,DC=local'
Location = $null
}
)
DomainControllers = @(
[PSCustomObject]@{
Name = 'DC-DSC-01'
Enabled = $true
IsGlobalCatalog = $true
IsReadOnly = $false
IPv4Address = '10.15.75.250'
}
[PSCustomObject]@{
Name = 'DC-DSC-02'
Enabled = $true
IsGlobalCatalog = $true
IsReadOnly = $false
IPv4Address = '10.15.75.251'
}
)
}
$ADVerifyInfra = [ADInfrastructure]::New(
$ADVerifyConfig.Forest,
$ADVerifyConfig.Domain,
$ADVerifyConfig.Sites,
$ADVerifyConfig.Subnets,
$ADVerifyConfig.SiteLinks,
$ADVerifyConfig.DomainControllers
)
$ADVerifyInfra.snapshotDate = $snapShotDate
$ADVerifyInfra.ImportADSnapshot()
$ADVerifyInfra.RunValidation($ADVerifyInfra,$ADVerifyInfra.ADSnapshot,@('Forest','Domain'))
$ADVerifyInfra.RunValidation($ADVerifyInfra.ADSnapshot,$ADVerifyInfra,@('Forest','Domain'))
$ADVerifyInfra.RunValidation($ADVerifyInfra.ADSnapshot,$ADVerifyInfra,@('DomainControllers'))
$ADVerifyInfra.RunValidation($ADVerifyInfra,$ADVerifyInfra.ADSnapshot,@('DomainControllers'))
$ADVerifyInfra.RunValidation($ADVerifyInfra.ADSnapshot,$ADVerifyInfra,@())
$ADVerifyInfra.ActionHistory | Select-Object -Property TimeGenerated,Tags,MessageData
#endregion

The class is instantiated differently this time. The values are added externally. If you run GetCurrentConfig() at any point, it will rewrite the default values. Here are the results of the snapshot vs manual source first.

$ADVerifyInfra.RunValidation($ADVerifyInfra.ADSnapshot,$ADVerifyInfra,@('DomainControllers'))

mansnapshotvssource

The snapshot only has one DomainController, we never get to the second DomainController. Now if we switch parameters from positions…

$ADVerifyInfra.RunValidation($ADVerifyInfra,$ADVerifyInfra.ADSnapshot,@('DomainControllers'))

mansourcevssnapshot

Ah! DC-DSC-02 doesn’t exist in the snapshot so it will fail! There are always two sides to consider. RunValidation() makes it easier to test and verify both sides…

Bonus round

ActionHistory

I recently discovered the Information stream in PowerShell v5. I decided to make use of Write-Information to log activities as I go along. This makes for easier troubleshooting of actions and/or sequences of methods being executed, couldn’t hurt… 😉

actionhistory

ValidationResults

Saving the validation test result enables you to process the results in different ways.

For starters you can use Format-Pester by Erwan Quélin to generate documentation of the results. Now because it’s an object you can just as easily run a query:

$ADVerifyInfra.validationResults.Results.TestResult.Where{$_.Passed -eq $false}

You can even send a high-level overview to Slack (It’s on my to-do list).

Whew! I think I’ve covered all the essentials… Ok as promised the code:

Class ADInfrastructure{
[PSObject]$Forest
[PSObject]$Domain
[PSObject]$Sites
[PSObject]$Subnets
[PSObject]$Sitelinks
[PSObject]$DomainControllers
[String]$snapshotDate
[String]$exportFolder='C:\scripts\export\dsa'
[PSObject]$ADSnapshot
[PSObject[]]$validationResults
[PSObject[]]$ActionHistory
#Default Constructor
ADInfrastructure(){}
#Constructor
ADInfrastructure($frs,$dom,$sit,$sub,$stl,$dcs){
$this.Forest = $frs
$this.Domain = $dom
$this.Sites = $sit
$this.Sitelinks = $stl
$this.Subnets = $sub
$this.DomainControllers = $dcs
}
GetCurrentConfig(){
$MessageData = "Get current Active Directory configuration"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags 'Get','CurrentConfig','AD' | Select-Object *
$this.Forest = $(Get-ADForest)
$this.Domain = $(Get-ADDomain)
$this.DomainControllers = $(Get-ADDomainController -Filter *)
$this.Sites = $(Get-ADReplicationSite -Filter *)
$this.Subnets = $(Get-ADReplicationSubnet -Filter *)
$this.Sitelinks = $(Get-ADReplicationSiteLink -Filter *)
}
ImportADSnapshot(){
if(Test-Path "$($this.exportFolder)\ADSnapshot-$($This.snapshotDate).xml"){
$this.ADSnapshot = Import-Clixml "$($this.exportFolder)\ADSnapshot-$($This.snapshotDate).xml"
$MessageData = "Imported ADSnapshot from $($this.SnapshotDate)"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags 'Get','ADSnapshot','Found' | Select-Object *
}
Else{
$MessageData = "ADSnapshot from $($this.SnapshotDate) not found"
Write-Warning -Message $MessageData
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags 'Get','ADSnapshot','Missing' | Select-Object *
}
}
ExportADSnapshot(){
$MessageData = "Saving ADSnapshot"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags 'Save','ADSnapshot' | Select-Object *
$exportDate = Get-Date -Format ddMMyyyy
$this | Export-Clixml "$($this.exportFolder)\ADSnapshot-$($exportDate).xml" -Encoding UTF8
}
RunValidation($src,$tgt,$tag){
#Something with Tags
$MessageData = "Validating AD Configuration against saved snapshot from $($this.SnapShotDate)"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags 'Validation','ADSnapshot','CurrentConfig','AD' | Select-Object *
$sbValidation = {
Param($Source,$Target)
Describe 'AD Forest configuration operational readiness' -Tags Forest {
Context 'Verifying Forest Configuration'{
it "Forest Name $($Source.Forest.Name)" {
$Source.Forest.Name |
Should be $Target.Forest.Name
}
it "Forest Mode $($Source.Forest.ForestMode)" {
$Source.Forest.ForestMode |
Should be $Target.Forest.ForestMode
}
it "$($Source.Forest.DomainNamingMaster) is DomainNamingMaster" {
$Source.Forest.DomainNamingMaster|
Should be $Target.Forest.DomainNamingMaster
}
it "$($Source.Forest.DomainNamingMaster) is SchemaMaster"{
$Source.Forest.SchemaMaster |
Should be $Target.Forest.SchemaMaster
}
}
}
Describe 'AD GlobalCatalog configuration operational readiness' -Tags GlobalCatalog {
Context 'Verifying GlobalCatalogs'{
$Source.Forest.GlobalCatalogs |
ForEach-Object{
it "Server $($_) is a GlobalCatalog"{
$Target.Forest.GlobalCatalogs.Contains($_) |
Should be $true
}
}
}
}
Describe 'AD Domain configuration operational readiness' -Tags Domain{
Context 'Verifying Domain Configuration'{
it "Domain DN is $($Source.Domain.DistinguishedName)" {
$Source.Domain.DistinguishedName |
Should be $Target.Domain.DistinguishedName
}
it "$($Source.Domain.InfrastructureMaster) is InfrastructureMaster"{
$Source.Domain.InfrastructureMaster |
Should be $Target.Domain.InfrastructureMaster
}
it "$($Source.Domain.PDCEmulator) is PDCEmulator"{
$Source.Domain.PDCEmulator |
Should be $Target.Domain.PDCEmulator
}
it "$($Source.Domain.RIDMaster) is RIDMaster"{
$Source.Domain.RIDMaster |
Should be $Target.Domain.RIDMaster
}
}
}
Describe 'AD DomainControllers configuration operational readiness' -Tags DomainControllers {
$lookupDC = $Target.DomainControllers | Group-Object -AsHashTable -AsString -Property Name
ForEach($dc in $Source.DomainControllers){
Context "Verifying DC $($dc.Name) Configuration"{
it "Is enabled " {
$dc.Enabled | Should be $lookupDC.$($dc.Name).Enabled
}
it "Is GC " {
$dc.IsGlobalCatalog | Should be $lookupDC.$($dc.Name).IsGlobalCatalog
}
it "ReadOnly is $($dc.IsReadOnly) " {
$dc.IsReadOnly| Should be $lookupDC.$($dc.Name).IsReadOnly
}
it "IPv4Address is $($dc.IPv4Address)" {
$dc.IPv4Address | Should be $lookupDC.$($dc.Name).IPv4Address
}
}
}
}
Describe 'AD Sites operational readiness' -Tags Sites {
Context 'Verifying Sites'{
$Source.Sites |
ForEach-Object{
it "Site $($_.Name)"{
$Target.Sites.Name.Contains($_.Name) |
Should be $true
}
}
}
}
Describe 'AD Subnets operational readiness' -Tags Subnets{
$lookupSubnets = $Target.SubNets | Group-Object -AsHashTable -AsString -Property Name
ForEach($subnet in $Source.Subnets){
Context "Verifying Subnet $($subnet.Name)"{
it "Subnet name is $($subnet.Name)"{
$subnet.Name | Should be $lookupSubnets.$($subnet.Name).Name
}
it "Subnet location is $($subnet.Location)"{
$subnet.Location | Should be $lookupSubnets.$($subnet.Name).Location
}
it "Subnet associated site is $($subnet.Site)"{
$subnet.Site | Should be $lookupSubnets.$($subnet.Name).Site
}
}
}
}
Describe 'AD Sitelinks operational readiness' -Tags SiteLinks {
$lookupSiteLinks = $Target.Sitelinks | Group-Object -AsHashTable -AsString -Property Name
ForEach($sitelink in $Source.Sitelinks){
Context "Verifying Sitelink $($sitelink.Name)"{
it "Sitelink name is $($sitelink.Name)"{
$sitelink.Name | Should be $lookupSiteLinks.$($sitelink.Name).Name
}
it "Sitelink cost is $($sitelink.Cost)"{
$sitelink.Cost | Should be $lookupSiteLinks.$($sitelink.Name).Cost
}
it "Sitelink replication frequency (min) is $($sitelink.ReplicationFrequencyInMinutes)"{
$sitelink.ReplicationFrequencyInMinutes| Should be $lookupSiteLinks.$($sitelink.Name).ReplicationFrequencyInMinutes
}
}
}
}
}
$pesterFile = "$($this.exportFolder)\ADInfra.tests.ps1"
$sbValidation.ToString() | out-file -FilePath $pesterFile -Force
$testADInfra = @(
@{
Path = $pesterFile
Parameters = @{
Source = $src
Target = $tgt
}
}
)
$this.ValidationResults += [PSCustomObject]@{
ValidationDate = $(Get-Date)
Results = Invoke-Pester -Path $testADInfra -PassThru -Tag $tag
}
}
}

Classes will definitely enhance your end-user’s experience…

Hope it’s worth something to you…

Ttyl,

Urv

Verify GroupMembership with Pester

‘Sup PSHomies,

Here’s another advantage of adding members with a different approach, Pester validation!

This makes for an easy way to process validation of  each member of a group.

Quick update: I’ve added  some extra code (at the end of the previous blog code) to export added- and revoked members.

#region Export for futher processing
$GroupMembers =@{
  Groups  = $Header
  Added   = $addADGroupMembers
  Revoked = $delADGroupMembers
}
$GroupMembers |
Export-Clixml .\export\dsa\ADGroupMembers-$exportDate.xml -Encoding UTF8
#endregion

Quick rundown, first we’ll import the saved object and used that to get a snapshot of the current group members. Then it’s time to vaildate who has been added or revoked.

<#
Author: I. Strachan
Version:
Version History:
Purpose: Validate group membership being added or revoked
#>
[CmdletBinding()]
param(
$xmlFile = 'ADGroupMembers-12102016.xml'
)
#Get saved group members from xmlFile
$SavedGroupMembers = Import-Clixml .\export\dsa\$xmlFile
#region Get current Group memberships
$SnapshotADGroupMembers = @{}
$SavedGroupMembers.Groups |
ForEach-Object{
$SnapshotADGroupMembers.$($_.SamAccountName) = Get-ADGroupMember -Identity $_.SamAccountName| Select-Object -ExpandProperty SamAccountName
}
#endregion
#region Verify members being revoked.
$SavedGroupMembers.Revoked.Keys |
ForEach-Object{
$GroupName = $_
if($SnapshotADGroupMembers.$GroupName){
Describe "AD GroupMembership revoked operational readiness for $GroupName" -Tags Revoked{
Context "Verifying users whose membership has been revoked from $GroupName."{
$SavedGroupMembers.Revoked.$GroupName |
ForEach-Object{
It "User $($_) is not a member of $($GroupName)"{
!($SnapshotADGroupMembers.$GroupName.Contains($_)) | Should be $true
}
}
}
}
}
}
#endregion
#region Verify members being added.
$SavedGroupMembers.Added.Keys |
ForEach-Object{
$GroupName = $_
if($SnapshotADGroupMembers.$GroupName){
Describe "AD GroupMembership added operational readiness for $GroupName" -Tags Added{
Context "Verifying users who are members of $GroupName."{
$SavedGroupMembers.Added.$GroupName |
ForEach-Object{
It "User $($_) is a member of $($GroupName)"{
($SnapshotADGroupMembers.$GroupName.Contains($_)) | Should be $true
}
}
}
}
}
}
#endregion
#region Save Current membership for future reference
$SnapshotADGroupMembers |
Export-Clixml .\export\dsa\SnapshotADGroupMembers-$exportDate.xml -Encoding UTF8
#endregion

adgroupmemberresults

In this case I wanted to generate different Describe blocks. This makes for a better distribution in the HTML report.

adgroupmembernunithtml

Here’s the code to generate the HTML report using reportunit.exe

#region
$exportDate = Get-Date -Format ddMMyyyy
#endregion

#region Main
$pesterGroupMembers = Invoke-Pester .\ps1\dsa\ADGroupMembers*  -OutputFile .\export\dsa\ADGroupMembers.NUnit.xml -OutputFormat NUnitXml -PassThru

#run reportunit against ADgroupMembers.NUnit.xml and display result in browser
&amp; .\tools\ReportUnit\reportunit.exe .\export\dsa\ADGroupMembers.NUnit.xml
Invoke-Item .\export\dsa\ADGroupMembers.NUnit.html

#Export Pester results to xml
$pesterGroupMembers | Export-Clixml .\export\dsa\PesterResults-GroupMembers-$($exportDate).xml -Encoding UTF8
#endregion

Making sure a user is a member can be tricky at times especially when the members list is a few hundred.

As always, snapshots are your friend! When I exported the groups the first time I did it without validating if they existed. I recently ran into a situation where AD Objects were being deleted and recreated using the same SamAccountName! So having a little more information than just the SamAccountName can help when troubleshooting now and in the future.

When my project manager asked for logs and I handed him the HTML generated report of the group members… You should have seen the glee on his face!

So there you have it, verfying group membership using Pester!

Hope it’s worth something to you…

Ttyl,

Urv

Add Members to Group – a different approach

‘Sup PSHomies,

My Project Manager is slowly becoming a  true PowerShell believer! Of course he doesn’t have to do the actual scripting, that’s where I come in… 😉

So in walks the PM…

PM: “Say Urv, if I gave you an excel worksheet with the user/group relationship, think you’d be able to script it?”

Me:”Gee, this is so sudden, let me think about it and get back to you asap… (Grinning)”

Here’s an impression of the worksheet:

excel-groupmembers

I’ve done this in past by just going through each user adding each group. I remember reading a post by Mike F. Robbins on how this could be done more efficiently! I very much like this approach. I’ve added a lil’ extra to the mix. Let’s dig in!

Well for starters I’ll just get the source directly from the excel file using D. Finke’s ImportExcel Module. No need to convert to CSV first.

$xlsxADGroupMembers = Import-Excel .\source\xlsx\$xlsxFile -WorkSheetname $WorkSheet

The other thing was retrieving the GroupNames from PSCustomObject by selecting MemberType ‘NoteProperty’, I got this as a tip on my own blog by Dirk. This way you’re not depending on the position of where the group names start in the header.

#Select Group names from Object
$Header = $xlsxADGroupMembers |
   Get-Member -MemberType NoteProperty |
   Where-Object{$_.Name -ne  'UserID'} |
   Select-Object -ExpandProperty Name

This makes it just a bit resilient.

I also decided to use hashtable to store the results first before processing the group membership. This way I can also export the results for future reference (Always keep a log)

#Create empty hashtables
$addADGroupMembers = @{}
$delADGroupMembers = @{}

#Get Group membership
$Header |
ForEach-Object{
   $Group = $_
   $addADGroupMembers.$Group  = $xlsxADGroupMembers.Where{$_.$Group -eq '1'} | Select-Object -ExpandProperty 'UserID'
   $delADGroupMembers.$Group = $xlsxADGroupMembers.Where{$_.$Group -ne '1'} | Select-Object -ExpandProperty 'UserID'
}

Now it’s time to add the members to specified groups

$Header |
ForEach-Object{
   if($addADGroupMembers.$_){
      try{
         Add-ADGroupMember -Identity $_ -Members $addADGroupMembers.$_
      }
      catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]{
         Write-Warning "AD Object $($Error[0].CategoryInfo.TargetName) not found"
      }
   }

   if($delADGroupMembers.$_){
      try{
         Remove-ADGroupMember -Identity $_ -Members $delADGroupMembers.$_ -Confirm:$false
      }
      catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]{
         Write-Warning "AD Object $($Error[0].CategoryInfo.TargetName) not found"
      }
   }
}

I’m using Try/Catch to catch any errors on AD Objects not existing. Without it you’d get quite a few errors if the AD objects don’t exists.

Here’s the full script:

<#
Author: I. Strachan
Version:
Version History:
Purpose: Add group member from excel worksheet. Scripts is based on Mike Robin's work
http://tinyurl.com/jfmw4o7
#>
[CmdletBinding()]
param(
$xlsxFile = 'Demo-Kruisjes.xlsx',
$WorkSheet = 'Demo'
)
#region
$exportDate = Get-Date -Format ddMMyyyy
#endregion
#Import Worksheet Using D. Finke's ImportExcel module http://tinyurl.com/lbhkhbd
$xlsxADGroupMembers = Import-Excel .\source\xlsx\$xlsxFile -WorkSheetname $WorkSheet
#Select Group names from Object
$Header = $xlsxADGroupMembers |
Get-Member -MemberType NoteProperty |
Where-Object{$_.Name -ne 'UserID'} |
Select-Object -ExpandProperty Name
#Create empty hashtables
$addADGroupMembers = @{}
$delADGroupMembers = @{}
#Get Group membership
$Header |
ForEach-Object{
$Group = $_
$addADGroupMembers.$Group = $xlsxADGroupMembers.Where{$_.$Group -eq '1'} | Select-Object -ExpandProperty 'UserID'
$delADGroupMembers.$Group = $xlsxADGroupMembers.Where{$_.$Group -ne '1'} | Select-Object -ExpandProperty 'UserID'
}
#region Main. Add and remove users to/from groups
$Header |
ForEach-Object{
if($addADGroupMembers.$_){
try{
Add-ADGroupMember -Identity $_ -Members $addADGroupMembers.$_
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]{
Write-Warning "AD Object $($Error[0].CategoryInfo.TargetName) not found"
}
}
if($delADGroupMembers.$_){
try{
Remove-ADGroupMember -Identity $_ -Members $delADGroupMembers.$_ -Confirm:$false
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]{
Write-Warning "AD Object $($Error[0].CategoryInfo.TargetName) not found"
}
}
}
#endregion
#region Export for futher processing
$GroupMembers =@{
Groups = $Header | Get-ADGroup -Properties WhenCreated
Added = $addADGroupMembers
Revoked = $delADGroupMembers
}
$GroupMembers |
Export-Clixml .\export\dsa\ADGroupMembers-$exportDate.xml -Encoding UTF8
#endregion

I like the fact that you can use the list to add, but also delete if it isn’t necessary. Of course if you fill it in wrong, then there is no fixing that. So be warned!

Another thing is that members can be added manually or another process, so don’t be surprised, when you’re evaulating the group membership….

Tip: Save the  GroupMembership just to be sure…

#Get current Group memberships
$SnapshotADGroupMembers = @{}

$Header |
ForEach-Object{
   $SnapshotADGroupMembers.$($_) = Get-ADGroupMember -Identity $_ | Select-Object -ExpandProperty SamAccountName
}

Next time I’ll blog about how we could to this in reverse… Stay tuned!

Hope it’s worth something to you,

Ttyl,

Urv

Active Directory ReportUnit Pester results

‘Sup PSHomies,

As excited as I get whenever I see all purple (or is it magenta? 😛 ) and green on my test results, how do you report your results? I saw this great article on reporting against Pester results by Dirk Bremen. I did see a screenshot of this somewhere on twitter but it didn’t register at that time. So having this article was great! You should definitely read his blog!

When I saved the Active Directory operational readiness, I realized that on screen it looked great, but as a report it was kinda flat. So I went back and looked at the code, I had only on Describe!

I decided to categorize the test by functionality and give the test appropriate Tags. Giving the tests tags helps target a specific test, no need to run the whole test to get a specific test 😉

Describe AD

Save the output to a XML file. I saw Flynn Bundy use the -PassThru switch to save the results as an object.

Pester ReportUnit

I also exported the results to XML just for good measure. You can always refer to past results if necessary…

Here’s a screenshot with the Forest operational test expanded:

ReportUnit AD Forest

Nice!

A little Slack notification on the side please!

Flynn also used Slack to send a notification. As luck would have it @pscookiemonster has a Slack Module aptly named, wait for it… PSSlack! Warren’s take on Slack is definitely worth reading if you’re considering using Slack as  a way to communicate with team members. I’m quite notorious for stalking my colleagues with unsolicited notification/reports. Slack is a great way to communicate without feeling pushy about it. Information is there, feel free to read it or don’t, your call.

I’m using the summary count of the Pester results for notification purposes.

SlackNotification

Quick tip on the token: Jaap Brasser has a blog about saving credentials safely. Your Slack token should be treated as careful as a password. I saved the token as a password using the Slack team name as the username for reference. This makes retrieving the token simple.

SlackToken-Hash

Here’s what the notification looks like in Slack

SlackNotification Resullts

Warren’s module is one to watch!

Ok here the code all put together:

<#
Author: I. Strachan
Version:
Version History:
Purpose: Get Active Directory Report Unit tests and send notification to Slack
#>
#region import module and saved credentials/tokens
Import-Module PSSlack
#Jaap Brassers blog on saving credentials. Saved slack's token as a password
#http://www.jaapbrasser.com/quickly-and-securely-storing-your-credentials-powershell/
$savedCreds = Import-CliXml -Path "${env:\userprofile}\Hash.Cred"
$token = $savedCreds.'udm-slack'.GetNetworkCredential().Password
$exportDate = Get-Date -Format ddMMyyyy
#endregion
#region Main
$pesterADDS = Invoke-Pester .\ps1\dsa\AD.Operations* -OutputFile .\export\adds\ADConfiguration.NUnit.xml -OutputFormat NUnitXml -PassThru
#run reportunit against DFSnShares.NUnit.xml and display result in browser
& .\tools\ReportUnit\reportunit.exe .\export\adds\ADConfiguration.NUnit.xml
Invoke-Item .\export\adds\ADConfiguration.NUnit.html
#Export Pester results to xml
$pesterADDS | Export-Clixml .\export\adds\PesterResults-ADDS-$($exportDate).xml -Encoding UTF8
#endregion
#region Send Slack notification of Pester results
$iconEmoji = @{$true = ':white_check_mark:';$false=':red_circle:'}[$pesterADDS.FailedCount -eq 0]
$color = @{$true='green';$false='red'}[$pesterADDS.FailedCount -eq 0]
#SlackFields
$Fields = [PSCustomObject]@{
Total = $pesterADDS.TotalCount
Passed = $pesterADDS.PassedCount
Failed = $pesterADDS.FailedCount
Skipped = $pesterADDS.SkippedCount
Pending = $pesterADDS.PendingCount
} | New-SlackField -Short
$slackAttachments = @{
Color = $([System.Drawing.Color]::$color)
PreText = 'Active Directory Pester Results'
AuthorName = '@irwins'
AuthorIcon = 'https://raw.githubusercontent.com/irwins/PowerShell-scripts/master/wrench.png&#39;
Fields = $Fields
Fallback = 'Your client is bad'
Title = 'Pester counts'
TitleLink = 'https://www.youtube.com/watch?v=IAztPZBQrrU&#39;
Text = @{$true='Everything passed';$false='Check failed tests'}[$pesterADDS.FailedCount -eq 0]
}
New-SlackMessageAttachment @slackAttachments |
New-SlackMessage -Channel 'powershell' -IconEmoji $iconEmoji -AsUser -Username '@irwins' |
Send-SlackMessage -Token $token
#endregion

Shout-out to you guys for your contribution to the PowerShell community! Keep it up! This is going to make Pester reporting and notifications way easier and not to mention cool! 😉

Hope it’s worth something to you

Ttyl,

Urv

 

 

 

 

PScribo for the win!

Sup’ PSHomies,

Is it just me or are there way too many cool development in the community happening right now? You know you’re addicted to PowerShell if your idea of unwinding on a Sunday evening is scripting! 😛

How I discovered PScribo

I was busy creating a server documentation script based on WMI. I’ve used sydi-server in the past, but I wanted it to be PowerShell-based for obvious reasons… 😉

My first idea was to create a well-defined XML document and use CSS to generate the HTML file. HTML had my preference. My first attempt was using the specialized XmlTextWriter object. That was only the first part, I had to do the CSS as well. It was a lot of code. At that time I didn’t make use of snapshots.

My next attempt was to use ConvertTo-HTML -Fragment -As (list,Table). This worked out pretty well for what I wanted, still it’s no PScribo. I’ve probably done three versions of this script.

As luck would have it, while working on the third version, the PSConfEu 2015 was taking place in StockHolm. I didn’t attend that one. Luckily for me, the presentations were uploaded. One presentation immediately caught my eye Documenting xxIT.

This is exactly what I wanted! HTML reporting capabilities!!! Having a Word version was a bonus! I downloaded the module and tried every example. Took me 2 hours to master the syntax. After that I never looked back!

PowerShell documentation made easy

PScribo has been brought to us by Iain Brighton.  Iain gave me some background information on how PScribo came to be:

I was helping Carl sort out a few “performance” issues with the Word functions he was using. As Carl’s documentation framework uses COM, it is painful! Here are a list of some of the issues:

  1. It’s slow
  2. When MS release a new version (or update) Word things can break
  3. There are localisation issues
  4. There is a reliance on Word actually being installed
  5. PS v5 has actually broken quite a lot of his code (although sped it up too)
  6. The code wasn’t factored particularly well, it is somewhat “better” now!

I told Carl I’d get rid of the Word dependency, improve performance and try to enforce a separation of concerns to aid maintenance. PScribo is the result of this.

So fun fact, I recently saw that Mr. Carl Webster is the guardian of Jeff Wouter’s ADHealthCheck script. A few lines into the script and I’m seeing a lot of Word configuration going on. I thought to myself this needs PScribo! In all my enthusiasm I sent a tweet to Mr Webster not knowing he already knew about PScribo 😛

Iain Twitt

Don’t I feel silly! I wasn’t trying to be cheeky or anything like that, I just thought I’d share what the advantages are of PScribo. I apologize for my ignorance. If I could just take a moment to demonstrate the awesomeness of PScribo, I’m sure you’ll be hooked as well!

Creating the ADHC snapshot

My mantra these days is to gather now and process later. I already did most of the grunt work with the Active Directory configuration snapshot. I just needed to extend it with Users, Privileged groups and Computers. With the snapshot, there’s no limitation where you get your source from. For Forest information, I gathered data using the Get-ADForest cmdlet and also [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest(). The latter also has sites,sitelinks and subnets data, which comes in handy if the Get-ADReplication* cmdlets aren’t available… Gather now, process later… 😉

Here’s what I came up with:

<#
Author: I.Strachan
Version:
Version History:
Purpose: Get Snapshot of Active Directory current Health
#>
[cmdletbinding()]
Param()
Import-Module ActiveDirectory,GroupPolicy -Verbose:$false
#region Helper Functions. Ideally this would be a module. You can also . Source an external script
Function Get-SitesStats{
#Ahsley McGlone Freaky neat AD site links
#https://blogs.technet.microsoft.com/ashleymcglone/2012/09/10/freaky-neat-active-directory-site-links-with-powershell/
Get-ADObject -LDAPFilter '(objectClass=site)' -SearchBase (Get-ADRootDSE).ConfigurationNamingContext -Properties WhenCreated, Description |
Select-Object Name,
@{label='IsEmpty';expression={If ($(Get-ADObject -Filter {ObjectClass -eq 'nTDSDSA'} -SearchBase $_.DistinguishedName)) {$false} else {$true}}},
@{label='DCCount';expression={@($(Get-ADObject -Filter {ObjectClass -eq 'nTDSDSA'} -SearchBase $_.DistinguishedName)).Count}},
@{label='SubnetCount';expression={@($(Get-ADObject -Filter {ObjectClass -eq 'subnet' -and siteObject -eq $_.DistinguishedName} -SearchBase (Get-ADRootDSE).ConfigurationNamingContext)).Count}},
@{label='SiteLinkCount';expression={@($(Get-ADObject -Filter {ObjectClass -eq 'sitelink' -and siteList -eq $_.DistinguishedName} -SearchBase (Get-ADRootDSE).ConfigurationNamingContext)).Count}},
WhenCreated,Description
}
Function Get-GPOsSoM {
#Ashley McGlone GPO Report
#https://blogs.technet.microsoft.com/ashleymcglone/2013/05/29/dude-wheres-my-gpo-using-powershell-to-find-all-of-your-group-policy-links/
BEGIN{
#region Get a list of all GPOs
$GPOs = Get-GPO -All |
Select-Object ID, Path, DisplayName, GPOStatus, WMIFilter
#endregion
#Array for GPLinks results
$gPLinks = @()
#region GPO Linked to the Domain
$domainGPO = @{
Identity = ((Get-ADDomain).distinguishedName)
Properties = @('name', 'distinguishedName', 'gPLink', 'gPOptions', 'canonicalname')
}
$gPlinks += Get-ADObject @domainGPO |
Select-Object 'name', 'distinguishedName', 'gPLink', 'gPOptions', 'canonicalname',
@{name='Depth';expression={0}}
#endregion
#region GPO Linked to OUs
$ouGPOs = @{
Filter = '*'
Properties = @('name', 'distinguishedName', 'gPLink', 'gPOptions', 'canonicalname')
}
$gPLinks += Get-ADOrganizationalUnit @ouGPOs |
Select-Object name, distinguishedName, gPLink, gPOptions ,canonicalname ,
@{name='Depth';expression={($_.distinguishedName -split 'OU=').count - 1}}
#endregion
#region GPOs linked to sites
$siteGPOs = @{
LDAPFilter = '(objectClass=site)'
SearchBase = "CN=Sites,$((Get-ADRootDSE).configurationNamingContext)"
SearchScope = 'Onelevel'
Properties = @('name', 'distinguishedName', 'gPLink', 'gPOptions', 'canonicalname')
}
$gPLinks += Get-ADObject @siteGPOs |
Select-Object name, distinguishedName, gPLink, gPOptions ,canonicalname,
@{name='Depth';expression={0}}
#endregion
#Hashtable to lookup GPOs
$lookupGPO = $GPOs | Group-Object -AsHashTable -Property 'Path'
}
PROCESS{
#Get the Scope of Management of each gPLink
ForEach ($SOM in $gPLinks) {
if ($SOM.gPLink) {
If ($SOM.gPLink.length -gt 1) {
$links = @($SOM.gPLink -split {$_ -eq '[' -or $_ -eq ']'} | Where-Object {$_})
For ( $i = $links.count - 1 ; $i -ge 0 ; $i-- ) {
$GPOData = $links[$i] -split {$_ -eq '/' -or $_ -eq ';'}
[PSCustomObject]@{
Depth = $SOM.Depth;
Name = $SOM.Name;
DistinguishedName = $SOM.distinguishedName;
canonicalName = $SOM.canonicalname;
PolicyDN = $GPOData[2];
LinkOrderNr = $links.count - $i
GUID = $lookupGPO.$($GPOData[2]).ID;
DisplayName = $lookupGPO.$($GPOData[2]).DisplayName;
GPOStatus = $lookupGPO.$($GPOData[2]).GPOStatus;
WMIFilter = $lookupGPO.$($GPOData[2]).WMIFilter.Name;
Config = $GPOData[3];
LinkEnabled = [bool](!([int]$GPOData[3] -band 1));
Enforced = [bool]([int]$GPOData[3] -band 2);
BlockInheritance = [bool]($SOM.gPOptions -band 1)
}
}
}
}
}
}
END{}
}
Function Get-PrivilegedGroups{
#Jeff Wouters script.
Param (
$Domain
)
BEGIN{
$PrivilegedGroups = @(
"$($Domain.DomainSID)-512" #Domain Admins
"$($Domain.DomainSID)-518" #Schema Admins
"$($Domain.DomainSID)-519" #Enterprise Admins
"$($Domain.DomainSID)-520" #Group Policy Creatr Owners
'S-1-5-32-544' #Builtin\Administrators
'S-1-5-32-548' #Builtin\Account Operators
'S-1-5-32-549' #Builtin\Server Operators
'S-1-5-32-550' #Builtin\Print Operators
'S-1-5-32-551' #Builtin\Backup Operators
'S-1-5-32-552' #Builtin\Replicators
'S-1-5-32-556' #Builtin\Network Configuration Operations
'S-1-5-32-557' #Builtin\Incoming Forest Trust Builders
'S-1-5-32-573' #Builtin\Event Log Readers
'S-1-5-32-578' #Builtin\Hyper-V Administrators
'S-1-5-32-580' #Builtin\Remote Management Users
)
}
PROCESS{
$objDomainPrivilegedGroups = @{
DomainSID = $Domain.DomainSID
NETBIOSName = $Domain.Name
FQDN = $Domain.DNSRoot
}
$colPrivilegedGroups = @()
foreach($group in $PrivilegedGroups){
$colPrivilegedGroups += Get-ADGroup -Identity $group -Properties Members,MemberOf
}
$objDomainPrivilegedGroups.Groups = $colPrivilegedGroups
[PSCustomObject]$objDomainPrivilegedGroups
}
END{}
}
#endregion
$snapshot = @{
ADDS = @{}
Users = @{}
Groups = @{}
GPOs = @{}
Computers = @{}
}
#region: snapshotADDS
$snapshot.ADDS.RootDSE = $(Get-ADRootDSE)
$snapshot.ADDS.Forest = $(Get-ADForest)
$snapshot.ADDS._Forest = [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest()
$snapshot.ADDS.Domain = $(Get-ADDomain)
$snapshot.ADDS._Domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()
$snapshot.ADDS.DomainControllers = $(Get-ADDomainController -Filter *)
$snapshot.ADDS.DomainTrusts = (Get-ADTrust -Filter *)
$snapshot.ADDS.DefaultPassWordPoLicy = $(Get-ADDefaultDomainPasswordPolicy)
$snapshot.ADDS.AuthenticationPolicies = $(Get-ADAuthenticationPolicy -LDAPFilter '(name=AuthenticationPolicy*)')
$snapshot.ADDS.AuthenticationPolicySilos = $(Get-ADAuthenticationPolicySilo -Filter 'Name -like "*AuthenticationPolicySilo*"')
$snapshot.ADDS.CentralAccessPolicies = $(Get-ADCentralAccessPolicy -Filter *)
$snapshot.ADDS.CentralAccessRules = $(Get-ADCentralAccessRule -Filter *)
$snapshot.ADDS.ClaimTransformPolicies = $(Get-ADClaimTransformPolicy -Filter *)
$snapshot.ADDS.ClaimTypes = $(Get-ADClaimType -Filter *)
$snapshot.ADDS.DomainAdministrators =$( Get-ADGroup -Identity $('{0}-512' -f (Get-ADDomain).domainSID) | Get-ADGroupMember -Recursive)
$snapshot.ADDS.OrganizationalUnits = $(Get-ADOrganizationalUnit -Filter *)
$snapshot.ADDS.OptionalFeatures = $(Get-ADOptionalFeature -Filter *)
$snapshot.ADDS.Sites = $(Get-ADReplicationSite -Filter *) #Applies To: Windows 8.1, Windows PowerShell 4.0, Windows Server 2012 R2
$snapshot.ADDS.Subnets = $(Get-ADReplicationSubnet -Filter *) #Applies To: Windows 8.1, Windows PowerShell 4.0, Windows Server 2012 R2
$snapshot.ADDS.SiteLinks = $(Get-ADReplicationSiteLink -Filter *) #Applies To: Windows 8.1, Windows PowerShell 4.0, Windows Server 2012 R2
$snapshot.ADDS.ReplicationMetaData = $(Get-ADReplicationPartnerMetadata -Target (Get-ADDomain).DNSRoot -Scope Domain)
$snapshot.ADDS.SitesStats = $(Get-SitesStats) #Courtesy of Ashley McGlone
$snapshot.ADDS.repadmin = $(repadmin.exe /showrepl * /csv | ConvertFrom-CSV) #Courtesy of Ashley McGlone
#endregion
#region snapshotGPOs
$snapshot.GPOs.GPOsSoM = Get-GPOsSoM
$snapshot.GPOs.All = Get-GPO -All | Select-Object '*'
#endregion
#region snapshotUsers
$snapshot.Users.Disabled = Search-ADAccount -AccountDisabled
$snapshot.Users.Expired = Search-ADAccount -AccountExpired
$snapshot.Users.Expiring = Search-ADAccount -AccountExpiring
$snapshot.Users.NoExpireDate = Get-ADUser -LDAPFilter '(|(accountExpires=0)(accountExpires=9223372036854775807))'
$snapshot.Users.Inactive = Search-ADAccount -AccountInactive
$snapshot.Users.NoKerberosPreAuth = get-aduser -filter * -properties DoesNotRequirePreAuth | Where-Object {$_.DoesNotRequirePreAuth}
$snapshot.Users.MustChangePassWord = Get-ADUser -Filter {pwdLastSet -eq 0}
$snapshot.Users.CannotChangePassWord = Get-ADUser -Filter * -Properties CannotChangePassword |Where-Object {$_.CannotChangePassword}
$snapshot.Users.All = Get-ADUser -Filter '*' | Select-Object '*'
#endregion
#region snapshotGroups
$snapshot.Groups.All = Get-ADGroup -Filter '*'
$snapshot.Groups.Privileged = Get-PrivilegedGroups -Domain $(Get-ADDomain)
#endregion
#region snapshotComputers
$snapshot.Computers.All = Get-ADComputer -Filter * -Properties OperatingSystem
$snapshot.Computers.Disabled = Search-ADAccount -AccountDisabled -ComputersOnly
$snapshot.Computers.Expired = Search-ADAccount -AccountExpired -ComputersOnly
$snapshot.Computers.Expiring = Search-ADAccount -AccountExpiring -ComputersOnly
#endregion
#region Export to XML. Change folder to reflect your location
$exportDate = Get-Date -Format ddMMyyyy
$snapshot | Export-Clixml .\export\adds\ADHC-$($exportDate).xml -Encoding UTF8
#endregion
#region Querying snapshot. Try each one seperately!
"`nDisabled users`n"
$snapshot.Users.Disabled | Select-Object Name
"`nExpired users`n"
$snapshot.Users.Expired | Select-Object Name
"`nExpiring users`n"
$snapshot.Users.Expiring | Select-Object Name
"`nPrivilegedGroup`n"
$snapshot.Groups.Privileged.Groups | Select-Object Name
"`nPrivilegedGroup Members count`n"
$snapshot.Groups.Privileged.Groups |
Foreach-object {
[PSCustomObject]@{
Name = $_.Name
MemberCount = @($_.Members).Count
}
} #From here you can do your own filtering
"`nGPOs`n"
$snapshot.GPOs.All | Select-Object DisplayName,ID
"`nGPOs Scope of Management Inheritance blocked`n"
$snapshot.GPOs.GPOsSoM | Select-Object Displayname,BlockInheritance,GUID
"`nMember servers`n"
$snapshot.Computers.All |
Where-Object {
($_.OperatingSystem -like '*server*') -and
(!($_.DistinguishedName -like '*OU=Domain Controllers*'))
} |
Select-Object Name
#endregion

The snapshot helps me concentrate on the task at hand. It’s also a great way to separate the documentation process from gathering all the Active Directory bits and pieces. This makes a huge difference in maintenance! If you need to add anything just update the snapshot.

Now for the fun part!

Creating the PScribo ADHC report

First I need to import the saved snapshot. Export-CliXml & Import-CliXml are growing on me!

#Get ADSnapshot
$ADHCSnapshot = Import-Clixml .\export\adds\ADHC-$($snapshotDate).xml

Next I need to initiate the Document. PScribo is DSL (Domain-specific Language) oriented. This makes the flow quite logical.

#region Create PScribo Document
$reportAD = Document &amp;amp;quot;ADHC snapshot report - $($snapshotDate)&amp;amp;quot; {
   GlobalOption -ForceUppercaseSection -EnableSectionNumbering -PageSize A4 -Margin 24
   BlankLine -Count 20
   Paragraph &amp;amp;quot;Active Directory Health report - $($snapshotDate)&amp;amp;quot;  -Style Title
   BlankLine -Count 20
   PageBreak
   TOC -Name 'Table of Contents'
   PageBreak

A “Document” is an object that contains one or more “sections”, TOC and Paragraphs just to name a few. Just have a look at the README.MD on github get an idea of what is possible and by all means try the examples!

To find the commands available in PScribo run

get-command -Module PScribo

Quick update: Iain just updated PScribo to include landscape page orientation!

Iain Twitt - Landscape

Now to break the document down into sections. Sections can be nested to create sub-sections much like you would do in Word with Header1, Header2, Header 3 etc. The TOC (Table of Content) will be generated according to how the sections are nested. You can also exclude a section from the TOC.

For ADHC, I started with the Forest Information

   Section -Style Heading1 'Forest Information' {
      $ADForest = [Ordered]@{
         Name = $($ADHCSnapshot.ADDS.Forest.Name)
         RootDomain = $($ADHCSnapshot.ADDS.Forest.RootDomain)
         ForestMode = $($ADHCSnapshot.ADDS.Forest.ForestMode.ToString())
         Domains = $($ADHCSnapshot.ADDS.Forest.Domains)
      }

      Table -Name 'Forest Information' -List -Width 0 -Hashtable $ADForest

For this part I created an ordered hashtable to generate a list

My favorite part is the following: Selecting the properties I want to report!

      Section -Style Heading2 'FSMO Roles' {
         $ADHCSnapshot.ADDS.Forest |
         Select-Object DomainNamingMaster,SchemaMaster |
         Table -Name 'FSMO Roles Forest' -List -Width 0

         Blankline

         $ADHCSnapshot.ADDS.Domain |
         Select-Object PDCEmulator,InfrastructureMaster,RIDMaster |
         Table -Name 'FSMO Roles Domain' -List -Width 0
      }

I demonstrated at the end of the ADHC snapshot how you can extract the information you want.

&amp;amp;quot;`nDisabled users`n&amp;amp;quot;
$snapshot.Users.Disabled | Select-Object Name

&amp;amp;quot;`nExpired users`n&amp;amp;quot;
$snapshot.Users.Expired  | Select-Object Name

&amp;amp;quot;`nExpiring users`n&amp;amp;quot;
$snapshot.Users.Expiring | Select-Object Name

To document, it’s as simple as selecting the properties and sending them down the pipeline.

Here’s where you do your formatting and filtering as well:

         Section -Style Heading3 'Privileged groups count'{

            #Create Style for Privileged Groups count greater than 5
            Style -Name PrivilegedGroupsGT5 -Color White -BackgroundColor Firebrick

            $PrivilegedGroupsGT5 = $ADHCSnapshot.Groups.Privileged.Groups |
            Foreach-object {
               [PSCustomObject]@{
                  Name = $_.Name
                  MemberCount = @($_.Members).Count
               }
            }

            #Set Style for Privileged Groups count greater than 5
            $PrivilegedGroupsGT5 | Where-object{ $_.MemberCount -gt 5} | Set-Style -Style 'PrivilegedGroupsGT5'

            Table -InputObject $PrivilegedGroupsGT5 -Name 'Privileged groups count' -Width 0
         }

For the privileged group member count I created a special style that would higlight any row where the count is greater than five.

Privileged Groups.PNG
How cool is that?!

Want a table with all the sites without a description?

         Section -Style Heading3 'Sites without a description' {
            $ADHCSnapshot.ADDS.Sites.Where{$_.Description -eq $null} |
            Select-Object Name |
            Table -Name 'Sites without a description' -Width 0
         }

Here the reporting part:

<#
Author: I.Strachan
Version: 1.0
Version History:
Purpose: Active Directory Health Check PScribo/Excel report
#>
[CmdletBinding()]
Param(
$snapshotDate= '26052016'
)
#PScribo link: https://github.com/iainbrighton/PScribo
Import-Module PScribo,ImportExcel -Verbose:$false
#ImportExcel link: https://github.com/dfinke/ImportExcel
Import-Module ImportExcel -Verbose:$false
#Get ADSnapshot
$ADHCSnapshot = Import-Clixml .\export\adds\ADHC-$($snapshotDate).xml
#region Create PScribo Document
$reportAD = Document "ADHC snapshot report - $($snapshotDate)" {
GlobalOption -ForceUppercaseSection -EnableSectionNumbering -PageSize A4 -Margin 24
BlankLine -Count 20
Paragraph "Active Directory Health report - $($snapshotDate)" -Style Title
BlankLine -Count 20
PageBreak
TOC -Name 'Table of Contents'
PageBreak
Section -Style Heading1 'Forest Information' {
$ADForest = [Ordered]@{
Name = $($ADHCSnapshot.ADDS.Forest.Name)
RootDomain = $($ADHCSnapshot.ADDS.Forest.RootDomain)
ForestMode = $($ADHCSnapshot.ADDS.Forest.ForestMode.ToString())
Domains = $($ADHCSnapshot.ADDS.Forest.Domains)
}
Table -Name 'Forest Information' -List -Width 0 -Hashtable $ADForest
Section -Style Heading2 'FSMO Roles' {
$ADHCSnapshot.ADDS.Forest |
Select-Object DomainNamingMaster,SchemaMaster |
Table -Name 'Forest FSMO Roles' -List -Width 0
Blankline
$ADHCSnapshot.ADDS.Domain |
Select-Object PDCEmulator,InfrastructureMaster,RIDMaster |
Table -Name 'Domain FSMO Roles' -List -Width 0
}
Section -Style Heading2 'Global Catalogs' {
$ADHCSnapshot.ADDS._Forest.GlobalCatalogs |
Select-Object Name |
Table -Name 'Global Catalogs' -Width 0
}
}
PageBreak
Section -Style Heading1 'Domain Information' {
$ADDomain = [Ordered]@{
NetBIOSName = $($ADHCSnapshot.ADDS.Domain.NetBIOSName)
DomainMode = $($ADHCSnapshot.ADDS.Domain.DomainMode.ToString())
DistinguishedName = $($ADHCSnapshot.ADDS.Domain.DistinguishedName)
DomainSID = $($ADHCSnapshot.ADDS.Domain.DomainSID)
}
Table -Name 'Domain Information' -List -Width 0 -Hashtable $ADDomain
Section -Style Heading2 'Domain Controllers' {
$ADHCSnapshot.ADDS.DomainControllers |
Select-Object Name,OperatingSystem,IPv4Address,Site |
Table -Name 'Domain Controllers' -List -Width 0
}
Section -Style Heading2 'Default Domain Password Policy' {
$ADHCSnapshot.ADDS.DefaultPassWordPoLicy |
Select-Object ComplexityEnabled,LockoutDuration,LockoutObservationWindow,LockoutThreshold,
MaxPasswordAge,MinPasswordAge,MinPasswordLength,PasswordHistoryCount,ReversibleEncryptionEnabled |
Table -Name 'Default Domain Password Policy' -List -Width 0
}
Section -Style Heading2 'Domain Administrators' {
$ADHCSnapshot.ADDS.DomainAdministrators |
Select-Object Name,DistinguishedName |
Table -Name 'Domain Administrators' -Width 0
}
PageBreak
Section -Style Heading2 'Organizational Units' {
$ADHCSnapshot.ADDS.OrganizationalUnits |
Select-Object Name,DistinguishedName |
Table -Name 'Organizational Units' -Width 0
}
PageBreak
Section -Style Heading2 'Groups' {
$ADHCSnapshot.Groups.Privileged |
Select-Object DomainSID,NETBIOSName,FQDN |
Table -Name 'Groups' -list -Width 0
Section -Style Heading3 'Privileged groups'{
$ADHCSnapshot.Groups.Privileged.Groups |
Foreach-Object{
[PSCustomObject]@{
Name = $_.Name
Category = $_.GroupCategory.ToString()
Scope = $_.GroupScope.ToString()
SID = $_.SID
}
} |
Table -Name 'Privileged groups' -Width 0
}
Section -Style Heading3 'Privileged groups count'{
#Create Style for Privileged Groups count greater than 5
Style -Name PrivilegedGroupsGT5 -Color White -BackgroundColor Firebrick
$PrivilegedGroupsGT5 = $ADHCSnapshot.Groups.Privileged.Groups |
Foreach-object {
[PSCustomObject]@{
Name = $_.Name
MemberCount = @($_.Members).Count
}
}
#Set Style for Privileged Groups count greater than 5
$PrivilegedGroupsGT5 | Where-object{ $_.MemberCount -gt 5} | Set-Style -Style 'PrivilegedGroupsGT5'
Table -InputObject $PrivilegedGroupsGT5 -Name 'Privileged groups count' -Width 0
}
}
}
PageBreak
Section -Style Heading1 'Sites & Subnets' {
Section -Style Heading2 'Sites' {
$ADHCSnapshot.ADDS.Sites |
Select-Object Name,Description,DistinguishedName |
Table -Name 'Sites & Subnets' -Width 0
Section -Style Heading3 'Sites without a description' {
$ADHCSnapshot.ADDS.Sites.Where{$_.Description -eq $null} |
Select-Object Name |
Table -Name 'Sites without a description' -Width 0
}
if($ADHCSnapshot.ADDS._Forest.Sites.Where{@($_.Subnets).Count -eq 0 }){
Section -Style Heading3 'Sites without a subnet' {
$ADHCSnapshot.ADDS._Forest.Sites.Where{@($_.Subnets).Count -eq 0 } |
Select-Object Name |
Table -Name 'Sites without a subnet' -Width 0
}
}
if($ADHCSnapshot.ADDS._Forest.Sites.Where{@($_.Servers).Count -eq 0 }){
Section -Style Heading3 'Sites without a DC' {
$ADHCSnapshot.ADDS._Forest.Sites.Where{@($_.Servers).Count -eq 0 } |
Select-Object Name |
Table -Name 'Sites without a DC' -Width 0
}
}
Section -Style Heading3 'Sites Stats' {
$ADHCSnapshot.ADDS.SitesStats |
Table -Name 'Sites Stats' -Width 0
}
}
Section -Style Heading2 'Sitelinks' {
$ADHCSnapshot.ADDS.SiteLinks |
Select-Object Name,Cost,ReplicationFrequencyInMinutes |
Table -Name 'Sitelinks' -Width 0
}
Section -Style Heading2 'Subnets' {
$ADHCSnapshot.ADDS.Subnets |
Select-Object Name,Site |
Table -Name 'Subnets' -Width 0
}
}
PageBreak
Section -Style Heading1 'Group Policies' {
$ADHCSnapshot.GPOs.All |
Select-Object DisplayName,Description,GPOStatus,ModificationTime |
Table -Name 'Group Policies' -Width 0
Section -Style Heading2 'Group Policy Scope of Management' {
$ADHCSnapshot.GPOs.GPOsSoM |
Select-Object DisplayName,LinkOrderNr,GPOStatus,LinkEnabled,Enforced,BlockInheritance |
Table -Name 'Group Policy Scope of Management' -Width 0
}
}
}
#endregion
#region Render report in HTML,Word & XML format
$reportAD | Export-Document -Path .\export\adds -Format Html,Word,XML
#Or save it and render later on.
$reportAD | Export-Clixml .\export\adds\ADHCRawPScriboDocument-$($snapshotDate).xml -Encoding UTF8
#endregion
#region Export Users,Group & Computers to Excel
$xlsxUserFile = ".\export\adds\ADHC - UserReport - $($snapshotDate).xlsx"
$xlsxComputerFile = ".\export\adds\ADHC - ComputerReport - $($snapshotDate).xlsx"
$xlsxGroupMembersFile = ".\export\adds\ADHC - PrivilegedMembersReport - $($snapshotDate).xlsx"
$xlsxGroupMemberOfFile = ".\export\adds\ADHC - PrivilegedMemberOfReport - $($snapshotDate).xlsx"
#Users xlsx file
foreach($entry in $ADHCSnapshot.Users.Keys){
$WorkSheetName = $entry
If($ADHCSnapshot.Users.$entry){
$ADHCSnapshot.Users.$entry |
ConvertTo-Csv -Delimiter ';' -NoTypeInformation |
ConvertFrom-Csv -Delimiter ';' |
Export-Excel -Path $xlsxUserFile -WorkSheetname $WorkSheetName -AutoSize -BoldTopRow -FreezeTopRow
}
}
#Computers xlsx file
foreach($entry in $ADHCSnapshot.Computers.Keys){
$WorkSheetName = $entry
if($ADHCSnapshot.Computers.$entry){
$ADHCSnapshot.Computers.$entry |
ConvertTo-Csv -Delimiter ';' -NoTypeInformation |
ConvertFrom-Csv -Delimiter ';' |
Export-Excel -Path $xlsxComputerFile -WorkSheetname $WorkSheetName -AutoSize -BoldTopRow -FreezeTopRow
}
}
#Groups xlsx file
$snapshot.Groups.Privileged.Groups.ForEach{
if($_.Members){
$_.Members |
ForEach-Object{
$_ | Get-ADObject |
Select-Object Name,DistinguishedName
} |
Export-Excel -Path $xlsxGroupMembersFile -WorkSheetname $_.Name -AutoSize -BoldTopRow -FreezeTopRow
}
if($_.MemberOf){
$_.MemberOf |
ForEach-Object{
$_ | Get-ADObject |
Select-Object Name,DistinguishedName
} |
Export-Excel -Path $xlsxGroupMemberOfFile -WorkSheetname $_.Name -AutoSize -BoldTopRow -FreezeTopRow
}
}
foreach($entry in $ADHCSnapshot.Computers.Keys){
$WorkSheetName = $entry
if($ADHCSnapshot.Computers.$entry){
$ADHCSnapshot.Computers.$entry |
ConvertTo-Csv -Delimiter ';' -NoTypeInformation |
ConvertFrom-Csv -Delimiter ';' |
Export-Excel -Path $xlsxComputerFile -WorkSheetname $WorkSheetName -AutoSize -BoldTopRow -FreezeTopRow
}
}
#endregion

Some cool tip & tricks courtesy of Iain

Well-defined XML File

Here’s something else you can do with PScribo: Generate a well-defined XML File!

Iain stated that there are bits of functionality missing. With a well-defined XML file you could do your own XML mapping as part of your Word document generation solution.

Export at a later time

You can export the “document” object with Export-CliXml if you wanted to generate a report later. That way you can recreate the “document” in any format, at any time! Gotta love Export-, Import-CliXml! 😉

Manipulate the object directly

It’s just a [PSCustomObject], but why mess with a good thing eh? Hehe…

Bonus: Excel reports for user, groups and computers

As a added bonus I also threw in some excel files for reporting Users, Groups and Computers using ImportExcel by  Doug Finke. Instead of exporting to csv just to cut/paste in Excel, why not just export directly to a xlsx file!

I hope this gives you an idea what the possibilities are when it comes to reporting. PScribo is awesome! Add ImportExcel to the mix and you’ve got all you need when it comes to documentation!

Hope it’s worth something to you

Ttyl,

Urv

 

Operational Readiness validation gotchas

Sup’ PSHomies,

Back from the PSConfEu 2016 in Hannover! It was awesome!!! It was great meeting so many in person! I highly recommend attending a conference if ever given the chance! Tobias did a great job organizing PSConfEU 2016!

The presentations were top notch! Two presentations I definitely wanted to follow were June Blenders’ Real world Pester TDD tests & Ravikanth Chaganti Operations Validation Framework. I enjoyed Ravikanth’s approach: a simplistic and a comprehensive test. June’s presentation was insightful! There were definitely a few aha moments for me! So I decided to re-evaluate what I had learned so far now that I’ve seen how it should be done! 😉 .

Simplistic tests

Think of simplistic tests as kicking the tires. Here’s where the obvious tests go:

  • Are the sets aligned?
  • Did the object count meet your expectation?

Things of that nature. Here are some tests to help you understand some gotcha moments.

Validating the count seems pretty straight forward right? Well, not always… I’ll explain…

When the sets to validate are identical validation is pretty straight forward. This is the best case scenario. I did one validation with and without Group-Object (I’ll explain later on).

Simplistic Identical sets - Count

Simplistic Indentical sets

By using Group-Object I can get the “real” count of a set. Group-Object will gauge the uniqueness of the set, but I also found a test where that might not always be useful… So my next test was to omit an entry from the Verify set

Simplistic Missing entry sets - Count

Simplistic missing an entry in Verify

The test failed as it should. Now for some fun, let’s add a double entry to the Verify set.

 

Simplistic Double entry sets - CountThis is an odd test, it could be a typo. Imagine having a list where double entries aren’t that obvious, this should catch it. Now ideally you’d use your source code that you used during deployment. I’m assuming you did automate your process eh? 😉 Without the Group-Object the count is identical. With Group-Object you only have one entry! This could explain an exception happened during deployment… Accidents will happen…

The last test is a fun one: different sets.

Simplistic Different sets - Count

Different sets

Surprise! Both validation count test passed, but the sets are totally different!

Bonus Test!

Simplistic Different sets double entry - Count

Different set with a double entry

This one almost got away. While doing the comprehensive test, it dawned on me that I should be testing both counts, with and without Group-Object. I updated the screenshot accordingly.

Take away Simplistic test:

Don’t only rely the count of a set. By using Group-Object you can gauge a set’s uniqueness. The bonus test showed that exceptions may happen. It’s totally valid as a starting point, that’s why it’s a simplistic tests. Here’s the code for the Simplistic test for count validation:

$savedADConfig= @{
   GlobalCatalogs = @(
      'DC-DSC-01.pshirwin.local'
      'DC-DSC-02.pshirwin.local'
   )
}

$verifyADConfig= @{
   GlobalCatalogs = @(
      'DC-DSC-01.pshirwin.local'
      'DC-DSC-02.pshirwin.local'
   )
}

#region Example Operational validation Simplistic test
Describe 'Active Directory configuration operational readiness' {
   Context 'Verifying GlobalCatalogs count without Group-Object'{
      it 'Total GlobalCatalogs match' {
         @($savedADConfig.GlobalCatalogs).Count |
         Should be @($verifyADConfig.GlobalCatalogs).Count
      }
   }

   Context 'Verifying GlobalCatalogs count with Group-Object'{
      it 'Total GlobalCatalogs match' {
         @($savedADConfig.GlobalCatalogs  | Group-Object).Count |
         Should be @($verifyADConfig.GlobalCatalogs | Group-Object).Count
      }
   }
}
#endregion

If you’d like to try out the simplistic tests just add/remove entries to the saved-/verifyADConfig sets. Now for the Comprehensive tests!

Comprehensive tests

Here’s where in-depth analysis goes. When I did the AD Operational Readiness test, I had a feeling I was missing something. I saw June using sort-object in one of her validations. That triggered me to re-valuate this test.

Comprehensive Identical sets

Comprehensive Identical sets

Depending on which set you used for your enumeration you could end up with different results. When the sets are identical, all goes well. Next test, omit an entry in Verify set.

Comprehensive Missing an entry

Comprehensive missing an entry

Enumerating from the saved test caught the missing entry, enumerating from verify didn’t. Both found ‘DC-DSC-01.pshirwin.local’. The simplistic test caught this, that is why you need both! Next up: Double entry in Verify.

Comprehensive Double entry

Comprehensive double entry in verify

Enumerating from the saved set caught the double entry. Enumerating from the verify set just enumerated the entry twice. If you’re visually inclined, you might miss this.

Hey all my tests results are green and purple! Yeah…

Last test: Different sets.

Comprehensive Different sets

Different sets

At this point you’re comparing apple with oranges. This should fail.

Take away comprehensive tests:

The set you’re enumerating from matters! To cover validation, best bet is to do both! Here’s the code for the Comprehensive Test:

#region Example Operational validation Comprehensive test
$savedADConfig= @{
   GlobalCatalogs = @(
      'DC-DSC-01.pshirwin.local'
      'DC-DSC-02.pshirwin.local'
   )
}

$verifyADConfig= @{
   GlobalCatalogs = @(
      'DC-DSC-01.pshirwin.local'
      'DC-DSC-02.pshirwin.local'
   )
}

Describe 'Active Directory configuration operational readiness' {
   Context 'Verifying GlobalCatalogs enumerating from saved configuration'{
      $savedADConfig.GlobalCatalogs |
      ForEach-Object{
         it "Server $($_) is a GlobalCatalog"{
            $verifyADConfig.GlobalCatalogs.Contains($_) |
            Should be $true
         }
      }
   }
   Context 'Verifying GlobalCatalogs enumerating from verify configuration'{
      $verifyADConfig.GlobalCatalogs |
      ForEach-Object{
         it "Server $($_) is a GlobalCatalog"{
            $savedADConfig.GlobalCatalogs.Contains($_) |
            Should be $true
         }
      }
   }
}
#endregion

Validation is great, but you need to make sure your validating with the right set in the right order!

Summary

  • Create Simplistic & Comprehensive tests.
  • Simplistic tests should take care of the obvious.
  • Comprehensive tests is where in-depth analysis takes place.
  • Validate by enumerating from both sets!
  • Never trust a test that doesn’t fail 😉

I’m glad I visited both presentations! Now it’s time to update my Operational readiness tests accordingly! 🙂

Hope it’s worth something to you

Ttyl,

Urv