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

ACLs Folder class

‘Sup PSHomies,

I recently had to make a quick Backup & Restore of ACLs three levels deep. Nothing fancy, just two functions, but that got me thinking…

Why not make a class of this?

And so I did! Here’s the framework for the class:

classACLFolders

Here’s a list of the methods:

  • Backup. Backup SDDL from the property $Folder of the class
  • Restore. Restore SDDL to the property $Folder of the class
  • Clone. Clone will take a target Folder and clone the SDDL of $Folder to it
  • ConvertSDDLToAccess. This will enumerate what the SDDL stands for

Default Constructor

classACLDefConstructor

The default constructor will evaluate the folder used to instantiate the object. If successful, the SDDL, Owner and Access is retrieved using  the Backup() method. All actions are registered for future reference.

Instantiating is pretty straightforward:

instantiateClass

Backup()

classACLBackup

This will retrieve the SDDL for the folder and enumerate the Access.

Restore()

classACLRestore

Restore is a bit tricky. For one you need to make sure it isn’t empty. Set-Acl has no problem setting an empty SDDL, blowing everything open (worst case scenario, and that’s why you should test this in a lab first!). The other challenge is having a valid SDDL string. You can change the SDDL string if you want to something gibberish, hence the typecast as a precaution.

Clone()

classACLDefClone

The same goes for cloning. In this case we need to test the target path. Alternatively, you could  also change the Folder to a new path… It works, you’d just have misleading ActionHistory entries… I wonder if there’s a way to make this read-only,  just thinking out loud here… (note to self)

ConvertSDDLToAccess()

This is just a lil’ something extra. Like I said in a previous blog SDDL really gives more information. For one, the SID will let you know which domain this object belongs to. One thing I ran into with ReACL is that SIDHistory will resolve to the current NTAccount. This had me puzzled for a while until I saw that the SIDs in SDDL where different.

Here’s what the ouput looks like:

convertSDDLToAccess

Now for those of you that are wondering just what is this AccessMask, wonder no more! 🙂

Remember the RoboCopy ExitCodes blog I did a while back? Well it’s the same principal 🙂 This is why classes & everything related should be on your radar…

enumAccessmask

Here’s how this works…

Say I wanted to evaluate the AccessMask of a SDDL entry

AccessMaskEnum
classACLConvertSDDLToAccess

Here I have the SID & the NTAccount. This is the builtin administrators account but it also works for Domain accounts.

There’s a private function that will translate the SID accordingly.

To see what the account can actually do we can enumerate the AccessMask

AccessMaskEnum1
AdvancedPermissions

This is what we’d see using the advanced Security GUI tab of a folder.

Not bad… Not bad at all…

I can’t state this enough, SDDL is NOT to be be trifled with. Yes you need admin rights and please try this is a testlab first.  SDDL is very potent, if used with caution, it could do a whole bit of good!

So finally, here’s the code…

[Flags()] Enum AccessMask{
Read = 1
Write = 2
Append = 4
ReadExtendedAttributes = 8
WriteExtendedAttributes = 16
Execute = 32
DeleteDirectory = 64
ReadAttributes = 128
WriteAttributes = 256
Delete = 65536
ReadSecurity = 131072
WriteACL = 262144
WriteOwner = 524288
Synchronize = 1048576
}
class aclsFolder{
[String]$Folder
[String]$SDDL
[String]$Owner
[PSObject[]]$Access
[PSObject[]]$ActionHistory
#Default Constructor
aclsFolder($fldr){
if(Test-Path -Path $fldr){
$this.Folder = $fldr
$Tags = @('Default','Constructor','valid')
$MessageData = "Path $($fldr) found"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
$this.Backup()
}
else{
$Tags = @('Default','Constructor','invalid')
$MessageData = "Path $($fldr) not found"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
}
#Methods
Backup(){
if(Test-Path $this.Folder){
$result = Get-Acl $this.Folder
$this.SDDL = $result.Sddl
$this.Owner = $result.Owner
$this.Access = $($result.Access | Select-Object File*,Access*,Identity*,IsInherited,*Flags)
$Tags = @('Backup','Success')
$MessageData = "Backup SDDL of $($this.Folder) was successful"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
else{
Write-Warning "Invalid Path $($this.Folder)"
$Tags = @('Backup','Failed')
$MessageData = "Backup SDDL of $($this.Folder) has failed"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
}
Restore(){
if((Test-Path $this.Folder) -and
![string]::isNullOrEmpty($this.SDDL) -and
[Security.AccessControl.RawSecurityDescriptor]$this.SDDL){
$acl = Get-Acl -Path $this.Folder
$acl.SetSecurityDescriptorSddlForm($this.SDDL)
Set-Acl -Path $($this.Folder) -AclObject $($acl)
$Tags = @('Restore', 'Success')
$MessageData = "Restoring SDDL on $($this.Folder) was succesful"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
#Reset values
$this.Backup()
}
else{
Write-Warning "Invalid Path $($this.Folder) or SDDL is invalid"
$Tags = @('Restore', 'Failed')
$MessageData = "Restoring SDDL on $($this.Folder) has failed"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
}
Clone($tgt){
if((Test-Path -Path $tgt) -and
![string]::isNullOrEmpty($this.SDDL) -and
[Security.AccessControl.RawSecurityDescriptor]$this.SDDL){
$acl = Get-Acl -Path $tgt
$acl.SetSecurityDescriptorSddlForm($this.SDDL)
Set-Acl -Path $($tgt) -AclObject $($acl)
$Tags = @('Clone', 'Success')
$MessageData = "Cloning SDDL on $($this.Folder) was succesful"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
else{
Write-Warning "Invalid Path $($this.Folder) or SDDL is invalid"
$Tags = @('Clone', 'Failed')
$MessageData = "Cloning SDDL on $($this.Folder) has failed"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
}
[PSObject]ConvertSDDLToAccess(){
Function Convert-SID2NTAccount{
param(
$SID
)
$objSID = New-Object System.Security.Principal.SecurityIdentifier($SID)
$objUser = $objSID.Translate( [System.Security.Principal.NTAccount])
$objUser
}
$accessSDDL = ([Security.AccessControl.RawSecurityDescriptor]$this.SDDL).DiscretionaryAcl |
ForEach-Object{
[PSCustomObject]@{
SID = $_.SecurityIdentifier
NTAccount = (Convert-SID2NTAccount -SID $_.SecurityIdentifier)
AceQualifier = $_.AceQualifier
AccessMask = $_.AccessMask
AceType = $_.AceType
AceFlags = $_.AceFlags
IsInherited = $_.IsInherited
InheritanceFlags = $_.InheritanceFlags
}
}
return $accessSDDL
}
}

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

RoboCopy class

‘Sup PSHomies,

It all started a year ago… Always wanting to learn anything PowerShell related, classes caught my eye ever since it was introduced in v5.  I wanted to try my hand at classes with a real life application… So I got on twitter for some tips…

powershell-class-tweet-2
powershell-class-tweet-3

Doug was kind enough to reach out and point me in the right direction, for which I owe him a great debt! Appreciate it Doug!!!

Like I said, I wanted to try my hand at classes with a real life application… If you’ve read my blogs then you’ll know that I’m a fan of robocopy, seriously, huge fan! . Did I mention how awesome robocopy is? 😛 I think I found my real life application 😉

When I started out with my Robocopy class, it was just about logging initially, but it could be so much more! Classes are native to v5. Now that v5 is mainstream I decided to finish the class. Richard Siddaway’s article  was just the spark I needed to get me going (again)!

Here’s what the Robocopy class looks like:

robocopy-class

Here a quick rundown on the properties:

The source/destination properties of the class are self explanatory (if you’re familiar with robocopy). The property logdir and JobID will be used to define a valid logfile name (with extension .log). Robocopy has quite a bit of options. I wanted to keep it as generic as possible. The property $this.Options is still a work in progress. The property $this.WildCards  is where you’ll define what will be filtered.  I’ll get back to rcCMDs and rcExitCodes later on…

These are the methods I came up with (so far, still a work in progress)

  • Mirror(). Mirrors $this.Source to $this.Destination with some default options
  • Move(). Moves this.Source to $this.Destination with some default options
  • RollBack(). Rollback $this.Destination to $this.Source with some default options
  • Sync(). Sync will synchronize the delta’s from $this.Source to $this.Destination using any additional $this.Options defined (at least that’s the idea). I’ve added a few options by default, mostly to exclude files and folders, think recycle.bin “System Volume Information” and the likes.
  • VerifyPaths(). This let’s you know if the $this.Source, $this.Destination and $this.LogDir are valid.
  • GetlistSource(). This will list the content of the $this.Source
  • GetListDestionation(). This will list the content of $this.Destination
  • GetLogSummary. This will return a summary of the log file (Hehe). The method is static so that you don’t have to instantiate the class in order to use it. (Thanks again Doug!)

The two methods: StartRCProcess and ExecuteRCCMD are actually helper methods. I just haven’t figured out how that works in classes. Ideally I’d like to have them hidden or as a function if that even makes sense. So here’s where they come in. At first I just executed robocopy with the necessary arguments. If you’re not interested in the exitcode then using ExecuteRCCMD is what you need. I wrote a blog about enumerating RoboCopy Exitcodes. Using $LastExitCode isn’t going to cut it if you decide to run robocopy jobs parallel. That’s where StartRCProcess comes in.Using Start-Process comes with an overhead of say 20 MB, which could add up in the long run. You do need to wait until the process has finished to retrieve the exitcode. If you really need the exitcode then StartRCProcess is what you need. The property $this.rcExitCodes will only be populated if StartRCProcess is used. Both will populate the $this.rcCMDs property.

Ok I think I’ve covered the basics, time to show some code! 😉

[Flags()] Enum RoboCopyExitCodes{
NoChange = 0
OKCopy = 1
ExtraFiles = 2
MismatchedFilesFolders = 4
FailedCopyAttempts = 8
FatalError = 16
}
Class RoboCopy{
[String]$Source
[String]$Destination
[String]$LogDir
[String]$JobID
[String]$Options
[String[]]$WildCards
[PSObject[]]$rcCMDs
[PSObject[]]$rcExitCodes
#default constructor
RoboCopy(){}
#constructor
RoboCopy([String]$src,[String]$des,[String]$jid,[String]$ld,[string[]]$wc){
$this.Source = $src
$this.Destination = $des
$this.JobID = $jid
$this.LogDir = $ld
$this.WildCards = $wc
}
[PSCustomObject]VerifyPaths(){
return [PSCustomObject]@{
Source = $(Test-Path -Path $this.Source)
Destination = $(Test-Path -Path $this.Destination)
LogDirectory = $(Test-Path -Path $this.LogDir)
}
}
StartRCProcess($source,$target,$params,$logFile,$tag){
#Save RoboCopy Command
$MessageData = $('robocopy "{0}" "{1}" {2} {3}' -f $source,$target,$($params -join ' '),$logFile)
$this.rcCMDs += Write-Information -MessageData $MessageData 6>&1 -Tags $tag | Select-Object *
#Execute Process
$rcArgs = "`"$($source)`" `"$($target)`" $params $logFile"
$rcResult = Start-Process robocopy -ArgumentList $rcArgs -WindowStyle Hidden -PassThru -Wait
#Save ExitCode
$this.rcExitCodes += [PSCustomObject]@{
ExitCode = [RoboCopyExitCodes]$rcResult.ExitCode
StartTime = $rcResult.StartTime
ExitTime = $rcResult.ExitTime
rcCMD = $MessageData
}
}
ExecuteRCCMD($source,$target,$params,$logFile,$tag){
#Save RoboCopy Command
$MessageData = $('robocopy "{0}" "{1}" {2} {3}' -f $source,$target,$($params -join ' '),$logFile)
$this.rcCMDs += Write-Information -MessageData $MessageData 6>&1 -Tags $tag | Select-Object *
#Execute Robocopy CMD
$rcArgs = @("$($source)","$($target)",$params,$logFile)
robocopy @rcArgs
}
Mirror(){
$tag = @('Mirror',$this.JobID)
$params = New-Object System.Collections.Arraylist
$params.AddRange(@('/MIR','/E','/BYTES','/NP','/NDL','/R:1','/W:1'))
$logFile = '/LOG:{0}\mirror-{1}.log' -f $this.LogDir,$this.JobID
$this.StartRCProcess($this.Source,$this.Destination,$params,$logFile,$tag)
#$this.ExecuteRCCMD($this.Source,$this.Destination,$params,$logFile,$tag)
}
Move(){
$tag = @('Move',$this.JobID)
$params = New-Object System.Collections.Arraylist
$params.AddRange(@('/COPY:DATS','/DCOPY:T','/SECFIX','/BYTES','/S','/E','/NP','/NDL','/R:1','/W:1','/MOVE'))
$logFile = '/LOG:{0}\move-{1}.log' -f $this.LogDir,$this.JobID
#$this.StartRCProcess($this.Source,$this.Destination,$params,$logFile,$tag)
$this.ExecuteRCCMD($this.Source,$this.Destination,$params,$logFile,$tag)
}
RollBack(){
$tag = @('RollBack',$this.JobID)
$params = New-Object System.Collections.Arraylist
$params.AddRange(@('/COPY:DATS','/DCOPY:T','/SECFIX','/BYTES','/S','/E','/NP','/NDL','/R:1','/W:1','/MOVE'))
$logFile = '/LOG:{0}\rollback-{1}.log' -f $this.LogDir,$this.JobID
#$this.StartRCProcess($$this.Source,$this.Destination,$params,$logFile,$tag)
$this.ExecuteRCCMD($this.Destination,$this.Source,$params,$logFile,$tag)
}
Sync(){
$tag = @('Sync',$this.JobID)
#Excluded Files & Directories
$XF = @(
'/XF'
'thumbs.db'
'~*.*'
'*.pst'
'desktop.ini'
'*.lnk'
)
$XD = @(
'/XD'
"`$Recycle.bin"
'Boot Recycler'
'IECompatCache'
'IEDownloadHistory'
'Cookies'
'WINDOWS'
'PrivacIE'
"`"System Volume Information`""
)
$Excluded = "$($XF -join ' ') $($XD -join ' ')"
$params = New-Object System.Collections.Arraylist
$params.AddRange(@($($this.WildCards),'/COPY:DATS','/SECFIX','/BYTES','/S','/PURGE','/E','/NP','/NDL','/R:1','/W:1'))
$logFile = '/LOG:{0}\sync-{1}.log' -f $this.LogDir,$this.JobID
#Save RoboCopy Command
$arrMessageDataInput = @(
$this.Source
$this.Destination
$($params -join ' ')
$($this.Options -join ' ')
$Excluded
$logFile
)
$MessageData = $('robocopy "{0}" "{1}" {2} {3} {4} {5}' -f $arrMessageDataInput )
$this.rcCMDs += Write-Information -MessageData $MessageData 6>&1 -Tags $tag | Select-Object *
#Execute Robocopy CMD
$rcArgs = @("$($this.Source)","$($this.Destination)",$params,$XF,$XD,$logFile)
robocopy @rcArgs
}
GetListSource(){
$tag = @('ListSRC',$this.JobID)
$params = New-Object System.Collections.Arraylist
$params.AddRange(@($($this.WildCards),'/L','/S','/E', '/BYTES','/FP','/NC','/NDL','/TS','/R:0','/W:0'))
$logFile = '/LOG:{0}\listSRC-{1}.log' -f $this.LogDir,$this.JobID
$this.StartRCProcess($this.Source,'NULL',$params,$logFile,$tag)
#$this.ExecuteRCCMD($this.Source,$this.Destination,$params,$logFile,$tag)
}
GetListDestination(){
$tag = 'ListDES'
$params = New-Object System.Collections.Arraylist
$params.AddRange(@($($this.WildCards),'/L','/S','/E','/BYTES','/FP','/NC','/NDL','/TS','/R:0','/W:0'))
$logFile = '/LOG:{0}\listDES-{1}.log' -f $this.LogDir,$this.JobID
#$this.StartRCProcess($this.Source,$this.Destination,$params,$logFile,$tag)
$this.ExecuteRCCMD($this.Destination,'NULL',$params,$logFile,$tag)
}
static [PSCustomObject]GetLogSummary([String]$rcLogFile){
filter Get-CapacitySize {
'{0:N2} {1}' -f $(
if ($_ -lt 1kb) { $_, 'Bytes' }
elseif ($_ -lt 1mb) { ($_/1kb), 'KB' }
elseif ($_ -lt 1gb) { ($_/1mb), 'MB' }
elseif ($_ -lt 1tb) { ($_/1gb), 'GB' }
elseif ($_ -lt 1pb) { ($_/1tb), 'TB' }
else { ($_/1pb), 'PB' }
)
}
$rcLogSummary = [PSCustomObject]@{
Start = $null
End = $null
LogFile = $null
Source = $null
Destination = $null
TotalDirs = $null
CopiedDirs = $null
FailedDirs = $null
TotalFiles = $null
CopiedFiles = $null
FailedFiles = $null
TotalBytes = $null
CopiedBytes = $null
FailedBytes = $null
TotalTimes = $null
Speed = $null
}
$rcLogSummary.LogFile = $rcLogFile.Split('\')[-1].ToLower()
$logFileContent = Get-Content $rcLogFile -Raw
[regex]$regex_Start = 'Started\s:\s+(?<StartTime>.+[^\n\r])'
if ($logFileContent -match $regex_Start){
$rcLogSummary.Start = $Matches['StartTime']
}
[regex]$regex_End = 'Ended\s:\s+(?<EndTime>.+[^\n\r])'
if ($logFileContent -match $regex_End){
$rcLogSummary.End = $Matches['EndTime']
}
[regex]$regex_Source = 'Source\s:\s+(?<Source>.+[^\n\r])'
if($logFileContent -match $regex_Source){
$rcLogSummary.Source = $Matches['Source'].Tolower()
}
[regex]$regex_Target = 'Dest\s:\s+(?<Target>.+[^\n\r])'
if($logFileContent -match $regex_Target){
$rcLogSummary.Destination = $Matches['Target'].ToLower()
}
[regex]$regex_Dirs = 'Dirs\s:\s+(?<TotalDirs>\d+)\s+(?<CopiedDirs>\d+)(?:\s+\d+){2}\s+(?<FailedDirs>\d+)\s+\d+'
if ($logFileContent -match $regex_Dirs){
$rcLogSummary.TotalDirs = [int]$Matches['TotalDirs']
$rcLogSummary.CopiedDirs = [int]$Matches['CopiedDirs']
$rcLogSummary.FailedDirs = [int]$Matches['FailedDirs']
}
[regex]$regex_Files = 'Files\s:\s+(?<TotalFiles>\d+)\s+(?<CopiedFiles>\d+)(?:\s+\d+){2}\s+(?<FailedFiles>\d+)\s+\d+'
if ($logFileContent -match $regex_Files){
$rcLogSummary.TotalFiles = [int]$Matches['TotalFiles']
$rcLogSummary.CopiedFiles = [int]$Matches['CopiedFiles']
$rcLogSummary.FailedFiles = [int]$Matches['FailedFiles']
}
[regex]$regex_Speed = 'Speed\s:\s+(?<Speed>.+\/min)'
if ($logFileContent -match $regex_Speed){
$rcLogSummary.Speed = $Matches['Speed']
}
$arrBytes = @(
'Bytes\s:\s+(?<TotalBytes>(\d+\.\d+\s)[bmg]|\d+)\s+' #TotalBytes
'(?<CopiedBytes>\d+.\d+\s[bmg]|\d+)\s+' #CopiedBytes
'(?:(\d+.\d+\s[bmg]|\d+)\s+){2}' #Skip two
'(?<FailedBytes>\d+.\d+\s[bmg]|\d+)' #FailedBytes
)
[regex]$regex_Bytes = -join $arrBytes
if ($logFileContent -match $regex_Bytes){
$rcLogSummary.TotalBytes = [int64]$Matches['TotalBytes'] | Get-CapacitySize
$rcLogSummary.CopiedBytes = [int64]$Matches['CopiedBytes'] | Get-CapacitySize
$rcLogSummary.FailedBytes = [int64]$Matches['FailedBytes'] | Get-CapacitySize
}
[regex]$regex_Times = 'Times\s:\s+(?<TotalTimes>\d+:\d+:\d+)'
if ($logFileContent -match $regex_Times){
$rcLogSummary.TotalTimes = $Matches['TotalTimes']
}
return $rcLogSummary
}
[String]GetRoboCopyCMD(){
$paramRoboCopy = @(
$this.Source
$this.Destination
$($this.WildCards -join ' ')
$this.Options
$('/LOG:{0}\{1}.log' -f $this.LogDir,$this.JobID)
)
return $('robocopy "{0}" "{1}" {2} {3} {4}' -f $paramRoboCopy)
}
}

Here what’s happening in the List methods:

methods-lists

GetListSource() is using $this.StartRCProcess to generate a list of $this.Source using some default option. While writing I noticed that I forgot to add the wildcards to the parameter. All I had to do was add it!. I added it at the beginning so it lines up accordingly… Robocopy is fickle like that…  GetListDestination does the same only it uses ExecuteRCCMD instead.

Here’s what’s going on in StartRCProcess and ExcuteRCCMD

startexecuterc

Both StartRCProcess and ExcuteRCCMD will save the robocopy command using Write-Information. I’m loving Write-Information more and more! StartRCProcess saves the exitcode with some extra information. Here’s where the robocopy exitcode enumeration came in handy! ExecuteRCCMD will run robocopy with the specified arguments. Truth be told I’m more partial to the ExecuteRCCMD method. I added the StartRCProcess more for demo purposes and finally getting to use my Robocopy exitcode enumeration!

For Mirror(),Move() and RollBack(), I omitted the Wildcards. These methods all or nothing in my opinion. If omitted, . will be the default.

Sync() had me going for a while. I still have some issues with Options. For now Sync() uses some default switches. Like I said work in progress…

Quite a bit of code, so does it work? Here’s some code to play with. be sure to edit the source,destination and logdir to your liking. Just remember that robocopy is unforgiving so make sure not to use it of production folders!

#region Main
$rc = [RoboCopy]::New('C:\scripts\move','C:\temp\move','rc-0001','c:\scripts\log',@('*.*'))

#Run RoboCopy methods
$rc.Sync()
$rc.GetListSource()
$rc.GetListDestination()

#Get RoboCopy LogFile Summary
[RoboCopy]::GetLogSummary("$env:HOMEDRIVE\scripts\log\listSRC-rc-0001.log")
[RoboCopy]::GetLogSummary("$env:HOMEDRIVE\scripts\log\listDES-rc-0001.log")
[RoboCopy]::GetLogSummary("$env:HOMEDRIVE\scripts\log\sync-rc-0001.log")

#Get RoboCopy executed CMDs
$rc.rcCMDs
$rc.rcExitCodes
#endregion

First I instantiate the class with arguments. I then run the methods Sync(),GetListSource() and GetListDestination(). Up next is retrieve the LogSummaries from the methods. Here’s a screenshot of the Sync LogSummary

synclogfile

I did a select of $rc.rcCMDs to give you an idea what is being stored

rc-rccmds

Only want ListDES?

$rc.rcCMDs |
Where-Object{$_.Tags -contains 'ListDes'} |
Select-Object -Property Time*,Tag*,Mess*
rc-rccmdswhereobject

The information stream is quite handy! The tags will definitely come in handy when you need to filter on action verb or job ID.

The methods GetListSource() & Mirror() both make use of StartRCProcess(), so let’s see what $rc.rcExitcode has shall we?

rcexitcodes

Nice!

This is by far my longest blog, if you made this far then… Congratulations! There’s still so much to discover when it comes to classes.

Classes definitely  makes your code look and feel more like a developer 😉 . I feel more comfortable giving a colleague this class than a bunch of scripts. In Richard’s article he’s using both classes and modules. There are sure to be some gotcha’s… Do you go all in with classes or only partial?

I’m hoping that the community can shed some light on the subject. I’d love to hear from you guys on how to improve on this… Let’s make this year, a year of PowerShell Classes! 😛

Hope it’s worth something to you…

Ttyl,

Urv

A simple logger using PowerShell Class

‘Sup PSHomies,

At our last DuPSUG meeting, Jaap Brassers did a short Demo on the latest addition to PowerShell Streams, Information. How did I miss this?

So what’s so great about the new Information stream? The Information stream solves the Write-Host issue of not sending output to a stream like: error;verbose;warning etc. As such we were told that Write-Host is evil and every time you use it a puppy dies… Who wants that on their conscious right?

Well with the Information stream Write-Host is allowed… no more puppy genocide!

Jaap showed us what the stream output looks like and how to write to the information stream. Here’s what the output looks like

informationstream

Here are a few properties we can play with: TimeGenerated,Source,User,Computer,Tags and MessageData. You get all of this by writing/redirecting to the information stream. The TimeGenerated property caught my eye, imagine creating a timeline of sorts. While googling (don’t act like it’s just me…) I came across a great article by Jeff Hicks on the subject (Seriously, how did I miss this?) In it he also talked about generating a timeline view… 😉

After I had some time to think about how to make use of this, it dawned on me… You know what else is native to PowerShell v5? Classes! When Classes were introduced in v5, they were another way to write DSC Resources. I’ve always thought that there’s more to classes than meets the eye.

If you’re interested in classes, Richard Siddaway has a great article taking you through each step, worth trying out.

Jaap’s demo gave me the idea to try my hand at a simple logger using classes. Here’s what I came up with…

Class WriteLog{
[string]$FileBaseName = 'Temp'
[string]$LogDirectory = 'C:\Scripts\export'
[string]$Preference = 'SilentContinue'
[bool]$Console = $true
[PSObject[]]$InfoMessages
#default constructor
WriteLog(){}
#constructor
WriteLog($fbn,$ld){
$this.FileBaseName = $fbn
$this.LogDirectory = $ld
}
WriteInformation($MessageData){
$info = $false
if ($this.Preference -eq 'Continue') { $Info = $true }
if ($Info) {
$this.InfoMessages += Write-Information -MessageData $MessageData 6>&1 | Select-Object *
}
if($this.Console){
Write-Information -MessageData $MessageData -InformationAction Continue
}
}
ExportToText(){
$this.InfoMessages |
Select TimeGenerated,MessageData |
Out-File "$($this.LogDirectory)\$($this.FileBaseName).txt" -Encoding utf8 -Force -Append -Width 1000
}
ExportToXML(){
$this.InfoMessages |
Export-Clixml -Path "$($this.LogDirectory)\$($this.FileBaseName).xml" -Encoding UTF8 -Force
}
}

Here’s a quick rundown:

construtors

The idea is to gather output from the Information stream. Constructors are a way of initiating an object for use (Shout out to Doug Finke for breaking this down for me!)

writeloginformation

I “borrowed” Jeff Hicks idea of validating the preference first. This way you can toggle between showing Write-Information and/or Write-Host data. You can use Write-Host for a more colorful experience. Did I already mention that Write-Host is cool again? :-P.  I also added the possibility to show data in the console if you choose to, a verbose action of sorts…

exportlogger

I also added two methods of exporting: text and xml. That’s where LogDirectory and FileBaseName come in. For the text export, I selected TimeGenerated and MessageData for a timeline view (I know I know Jeff Hicks did it first :-P). Export to xml saves InfoMessages in raw format.

Here’s some code to play with.

function Get-PowerShellProcess{
[cmdletbinding()]
param()

Write-Verbose "InformationPreference = $InformationPreference"
$Logger.Preference = $InformationPreference
$Logger.WriteInformation("Starting $($MyInvocation.MyCommand)")

Get-Process -Name *PowerShell*
$Logger.WriteInformation("Finishing $($MyInvocation.MyCommand)")
}

#region Main
#Initiate variable
$Logger = [WriteLog]::New('psProcess','C:\scripts\temp')

#Run with InformationAction. Information will be saved and not shown
Get-PowerShellProcess -InformationAction Continue -Verbose

#Run without InformationAction with Console set to $true
$Logger.Console = $true
Get-PowerShellProcess -Verbose

#Run without InformationAction with Console set to $false
$Logger.Console = $false
Get-PowerShellProcess -Verbose
#endregion

Here are some screenshots of the output

I initiated the object a FileBaseName value ‘psProcess’ and LogDirectory ‘C:\scripts\temp’. Default Preference is ‘SilentContinue’ and no ouput to the console

initiate

First up run with action set to continue

loggerinfoaction

$Logger.InfoMessages has redirected data.

loggerinfomessages

When console is set to true we’ll see the Information message without explicitly setting InformationAction to ‘continue’. Using InformationAction will save the Information data to InfoMessages property.

infostreamconsole

Setting the console preference back to $false stops displaying output to the console

infostreamconsolefalse

I hope that you were able to follow it all. 2016 was all about Operation Validation for me. I think classes are going to be mainstream in 2017! PowerShell + Classes will definitely give you an edge and not to mention make you feel more like a developer! 😉

The line between Dev and Ops is being blurred…

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

AD Security Group matrix

‘Sup PSHomies,

So last blog was about adding members to a security group efficiently. Which got me thinking, can I reverse this process? If given the security groups with specified members, can I recreate the csv? I do love me a challenge!

So to pick up where we left off, I’ll be using the $addADGroupMembers to repopulate the csv…

#Security Matrix
$Groups = $addADGroupMembers.Keys
function Convert-ArrayToHash($a){
    begin { $hash = @{} }
    process { $hash[$_] = $null }
    end { return $hash }
}

$template = [PSCustomObject]([Ordered]@{UserID=$null} + $($Groups | Convert-ArrayToHash))

$addADGroupMembers has all the group names we need. I’m converting the group names into an empty hashtable. The $template variable is a custom object I’ll be using to move things  along, I’ll explain as we go…

matrix-template

Now for the tricky part…

$arrMatrix = @()

$Groups |
ForEach-Object{
   $GroupName = $_
   if($addADGroupMembers.$_){
      $addADGroupMembers.$_ |
      ForEach-Object{
         if($arrMatrix.Count -eq 0) {
            $newItem = $template.PSObject.Copy()
            $newItem.UserID = $_
            $newItem.$GroupName = '1'
            $arrMatrix += $newItem
         }
         else{
            if($arrMatrix.UserID.contains($($_))){
               $index = [array]::IndexOf($arrMatrix.UserID, $_)
               $arrMatrix[$index].$GroupName = '1'
            }
            else{
               $newItem = $template.PSObject.Copy()
               $newItem.UserID = $_
               $newItem.$GroupName = '1'
               $arrMatrix += $newItem
            }
         }
      }
   }
}

First we have an array to save the results. We only need to worry about groups with members. The $template has been initialized with $null. The first time it runs, $arrMatrix.Count will be zero, so just add this group to get things started. Here’s where it gets interesting, in order to add a newItem to the array I have to clone it first. Adding and saving this way makes sure I have a row with a unique UserID. Truth be told I had to google to figure this one out. Modifying $template and then assigning it to $newItem will only assign the reference. Change the value once more and every item in the array changes! I read it somewhere, it was fun to stumble on this… The more you know…

The next trick was to find the index of a UserID already saved. Google to the rescue! I found this neat trick of using [array]::IndexOf(). This will give you the first index with that value. Lucky for me, my UserIDs are unique 😉
Once I have my index I can add a value of ‘1’ to the group if the UserID is a member. If I can’t find a UserID then a new unique UserID is added to the $arrMatrix

Ok enough chit-chat here’s some code to play with

#region Create GroupMember Hashtable
$csvMatrix = @"
UserID APP-MS Excel APP-MS Outlook APP-MS Powerpoint APP-MS Visio Viewer APP-MS Word APP-Adobe Reader
ejboogers 1 1 1 1 1
dlbouchlaghmi 1 1 1 1 1 1
sideroij 1 1 1 1 1
barnhoorn 1 1 1 1
bofechter 1 1 1 1
asschonewille 1 1 1 1
rrchouiter 1 1 1 1
mgragt 1 1 1 1 1
tpggrimbergen 1 1 1
bvanderhassel 1 1 1 1 1 1
jvderwilk 1 1 1 1 1
cdvanderheijden 1 1 1 1 1 1
thvjanssen 1 1 1 1
skalac-ivor 1 1 1 1 1
nvanderspeklap 1 1 1 1
mkrunder 1 1 1 1
nkoelewijk 1 1 1 1 1
jlekkerkernij 1 1 1 1
"@ | ConvertFrom-Csv -Delimiter "`t"
$csvMatrix |
Sort-Object -Property UserID |
Format-Table
$Header = $csvMatrix |
Get-Member -MemberType NoteProperty |
Where-Object{$_.Name -ne 'UserID'} |
Select-Object -ExpandProperty Name
$addADGroupMembers = @{}
$delADGroupMembers = @{}
$Header |
ForEach-Object{
$Group = $_
$addADGroupMembers.$Group = $csvMatrix.Where{$_.$Group -eq '1'} | Select-Object -ExpandProperty 'UserID'
$delADGroupMembers.$Group = $csvMatrix.Where{$_.$Group -ne '1'} | Select-Object -ExpandProperty 'UserID'
}
#endregion
#region Security Matrix
$Groups = $addADGroupMembers.Keys
function Convert-ArrayToHash($a){
begin { $hash = @{} }
process { $hash[$_] = $null }
end { return $hash }
}
$template = [PSCustomObject]([Ordered]@{UserID=$null} + $($Groups | Convert-ArrayToHash))
$arrMatrix = @()
$Groups |
ForEach-Object{
$GroupName = $_
if($addADGroupMembers.$_){
$addADGroupMembers.$_ |
ForEach-Object{
if($arrMatrix.Count -eq 0) {
$newItem = $template.PSObject.Copy()
$newItem.UserID = $_
$newItem.$GroupName = '1'
$arrMatrix += $newItem
}
else{
if($arrMatrix.UserID.contains($($_))){
$index = [array]::IndexOf($arrMatrix.UserID, $_)
$arrMatrix[$index].$GroupName = '1'
}
else{
$newItem = $template.PSObject.Copy()
$newItem.UserID = $_
$newItem.$GroupName = '1'
$arrMatrix += $newItem
}
}
}
}
}
$arrMatrix |
Sort-Object -Property UserID |
Select-Object 'UserID', 'APP-MS Excel','APP-MS OutLook','APP-MS Powerpoint','APP-MS Visio Viewer','APP-MS Word','APP-Adobe Reader' |
Format-Table
#endregion

This should be your endresult

securitymatrixresults

No too shabby eh? 😛

Ok Urv that’s all good and well but when am I going to use this?

Why thank you for asking!

If you’ve ever been in charge of implementing Role Based Access Control then you could appreciate this. A security matrix like this is where I’d start, only now you don’t have to start from scratch… 😉

Here’s how it works…

adsecuritymatrix

I created a security group Rol-Consultant for RBAC purposes. This group is a member of all the APP-* groups giving any member access by way of group nesting. Users who are a member of Rol-Consultant don’t have to be a direct member for access. The down side of RBAC is it’s all or nothing, exceptions are real deal breakers…

I did a blog about reporting a user’s nested group membership. Let take user ‘dlbouchlaghmi’. This is what his effective user group membership looks like in list form

nestusergroupmembership

The security matrix makes it a bit more visual. Granted, it takes some getting use to but the information is there. Now you can ‘fix’ any issues and reapply the way you see fit! 😉

This has been on my radar for quite some time. Processing security groups this way, makes scripting, I wouldn’t say easier, but more easier to manipulate if you catch my drift…

Ok here’s the code to get the ADSecuirtyMatrix. Do be careful with groups with large memberships. I tried my hand at a group with more than 3800 member, took a couple of minutes, but it worked.

#Security Matrix
#Specify your security groups. try small groups first
$Groups = @(
'APP-MS Outlook','APP-Adobe Reader','APP-MS Word',
'APP-MS Powerpoint','APP-MS Excel','APP-MS Visio Viewer','Rol-Consultant'
)
function Convert-ArrayToHash($a){
begin { $hash = @{} }
process { $hash[$_] = $null }
end { return $hash }
}
$template = [PSCustomObject]([Ordered]@{UserID=$null} + $($Groups | Convert-ArrayToHash))
$arrMatrix = @()
#Get current Group memberships
$SnapshotADGroupMembers = @{}
$Groups |
ForEach-Object{
$SnapshotADGroupMembers.$($_) = Get-ADGroupMember -Identity $_ | Select-Object -ExpandProperty SamAccountName
}
$Groups |
ForEach-Object{
$GroupName = $_
if($SnapshotADGroupMembers.$_){
$SnapshotADGroupMembers.$_ |
ForEach-Object{
if($arrMatrix.Count -eq 0) {
$newItem = $template.PSObject.Copy()
$newItem.UserID = $_
$newItem.$GroupName = '1'
$arrMatrix += $newItem
}
else{
if($arrMatrix.UserID.contains($($_))){
$index = [array]::IndexOf($arrMatrix.UserID, $_)
$arrMatrix[$index].$GroupName = '1'
}
else{
$newItem = $template.PSObject.Copy()
$newItem.UserID = $_
$newItem.$GroupName = '1'
$arrMatrix += $newItem
}
}
}
}
}
$arrMatrix |
Select-Object 'UserID', 'APP-MS Outlook','APP-Adobe Reader','APP-MS Word',
'APP-MS Powerpoint','APP-MS Excel','APP-MS Visio Viewer','Rol-Consultant' |
Out-GridView

I got one more use to go… Some Operation validation! Stay tuned…

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

Get DSA object creation date

‘Sup PSHomies,

The project manager is on to me! 😛

PM: “PowerShell is awesome!!!”

Me: “You know it!!!”

PM: “Glad you agree! So here’s what I need, We need a weekly update of all users, groups in the source domain based on their creation date…”

Me: “Wait, what just happened?

PM: “You were agreeing that PowerShell is awesome? (Grinning)”

Me: “Well played… I ain’t even mad mad at ya…”

And so begins another PowerShell journey 🙂

The idea here is to be proactive in our migration. Instead of asking for a list of newly created users/groups we’ll gather the information ourselves. All the customer has to do now is validate our list as to who is relevant. Which got me thinking, why stop at user/groups?

Remember I needed to recreate the DFS Structure? The data that I used was from February. What if there were newly created DFS links in the mean time? Turns out my hunch paid off!

Now curiosity got the better of me… “What about accounts that were deleted?” We’re migrating in batches. My first action is always to verify that the accounts in the batch exist. Having a list of deleted objects helps verifying it wasn’t a typo and that this object isn’t relevant anymore…

Here’s what I came up with:

<#
Author: I. Strachan
Version:
Version History:
Purpose: Export DSA Objects
#>
[CmdletBinding()]
param(
$domain = 'pshirwin.local'
)
#region Initiate HashTables & variables
$exportDate = Get-Date -Format ddMMyyyy
$xlsxFile = ".\export\dsa\source\$domain - DSAObjects - $exportDate.xlsx"
$xmlFile = ".\export\dsa\source\$domain - DSAObjects - $exportDate.xml "
$DSAObjects = @{}
$Select = @{}
#endregion
#region Main
$DSAObjects.DFSLinks = Get-ADObject -LDAPFilter '(objectClass=msDFS-LInkv2)'-Properties msDFS-LinkPathv2,msDFS-Propertiesv2,whenChanged,whenCreated |
ForEach-Object{
[PSCustomObject]@{
DFSLink = '\\pshirwin{0}' -f ($_.'msDFS-LinkPathv2').Replace('/','\')
State = $_.'msDFS-Propertiesv2' -join ','
Changed = $_.whenChanged
Created = $_.whenCreated
CreatedDate = Get-Date (Get-Date $_.whenCreated).Date -format yyyyMMdd
}
}
$DSAObjects.PrintQueues = Get-ADObject -LDAPFilter '(objectClass=printQueue)' -Properties printerName,portName,printShareName,uNCName,serverName,whenChanged,whenCreated |
ForEach-Object{
[PSCustomObject]@{
PrinterName = $_.printerName
PortName = $_.portName -join ','
PrintShareName = $_.printShareName -join ','
ServerName = $_.serverName
UNCName = $_.uNCName
Changed = $_.whenChanged
Created = $_.whenCreated
CreatedDate = Get-Date (Get-Date $_.whenCreated).Date -format yyyyMMdd
}
}
$DSAObjects.Contacts = Get-ADObject -LDAPFilter '(objectClass=contact)' -Properties DisplayName,givenName,sn,DistinguishedName,mail,whenChanged,whenCreated |
Foreach-Object {
[PSCustomObject]@{
GivenName = $_.givenName
SurName = $_.sn
DistinguishedName = $_.DistinguishedName
DisplayName = $_.DisplayName
EmailAddress = $_.mail
Changed = $_.whenChanged
Created = $_.whenCreated
CreatedDate = Get-Date (Get-Date $_.whenCreated).Date -format yyyyMMdd
}
}
$DSAObjects.Users = Get-ADUser -LDAPFilter '(objectClass=user)' -Properties accountExpirationDate,LastLogonDate,Initials,Description,EmailAddress,Enabled,DisplayName,OfficePhone,MobilePhone,Department,whenChanged,whenCreated,DistinguishedName,canonicalname |
Foreach-Object {
[PSCustomObject]@{
SamAccountName = $_.SamAccountName
DistinguishedName = $_.DistinguishedName
CanonicalName = $_.CanonicalName
Enabled = $_.Enabled
GivenName = $_.GivenName
Initials = $_.Initials
SurName = $_.SurName
EmailAddress = $_.EmailAddress
Description = $_.Description
Displayname = $_.DisplayName
OfficePhone = $_.OfficePhone
MobilePhone = $_.MobilePhone
Department = $_.Department
LastLogonDate = $_.LastLogonDate
AccountExpiresOn = $_.accountExpirationDate
Changed = $_.whenChanged
Created = $_.whenCreated
CreatedDate = Get-Date (Get-Date $_.whenCreated).Date -format yyyyMMdd
}
}
$DSAObjects.Groups = Get-ADGroup -LDAPFilter '(objectClass=group)' -Properties Member,MemberOf,whenChanged,whenCreated |
Foreach-Object {
[PSCustomObject]@{
DistinguishedName = $_.DistinguishedName
SamAccountName = $_.SamAccountName
Name = $_.Name
Changed = $_.whenChanged
Created = $_.whenCreated
CreatedDate = Get-Date (Get-Date $_.whenCreated).Date -format yyyyMMdd
Member = $_.Member
MemberOf = $_.MemberOf
}
}
$DSAObjects.Deleted = Get-ADObject -Filter * -IncludeDeletedObjects -Properties CN,SamAccountName,LastKnownParent | Where-Object{$_.Deleted -eq $true} |
ForEach-Object{
[PSCustomObject]@{
CN = $(($_.CN -split "`n") -join '; ')
SamAccountName = $_.SamAccountName
Deleted = $_.Deleted
ObjectClass = $_.ObjectClass
LastKnownParent = $_.LastKnownParent
}
}
#endregion
#region Export Object and to Excel
$Select.DFSLinks = '*'
$Select.Users = '*'
$Select.Contacts = '*'
$Select.Groups = @('ObjectClass', 'DistinguishedName', 'Name', 'Changed', 'Created')
$Select.PrintQueue = @('ObjectClass', 'PrinterName','PortName','PrintShareName','ServerName','UNCName','Changed','Created')
$Select.Deleted = @('CN','SamAccountName','Deleted','ObjectClass','LastKnownParent')
#XML File
$DSAObjects |
Export-Clixml -Path $xmlFile -Encoding UTF8
#Excel File
$DSAObjects.Keys |
ForEach-Object{
if($DSAObjects.$_){
$DSAObjects.$_ |
Select-Object $Select.$_ |
Export-Excel -Path $xlsxFile -WorkSheetname $_ -AutoSize -BoldTopRow -FreezeTopRow
}
}
#endregion

I added Contact and PrintQueues as a bonus 😉

For reporting it’s ImportExcel to the rescue!

dsaobjects

At first I added a extra property in order to lookup the creation date using yyyyMMdd date format. Turns out it wasn’t necessary. You can just a easily use a filter on Created

dsaobjects-filter

The PM is quite pleased… PowerShell is Awesome!!! But you already knew that… 😉

Hope it’s worth something to you

Ttyl,

Urv