Dynamically manage group memberships

Today I  was asked to create an email enabled group whos’ membership would always be up to date.   I’m lucky enough to be using Active Roles Server so my initial thought was to use that rather than a query based distribution list.  Active Roles has a built in capability to dynamically manage a groups’ membership and it’s pretty flexible and really easy to set up.  Best of all it’s completely transparent to AD that the group is anything other than a normal group.

All of the users that needed to be in the group, I was told, were in the same OU so that made it even easier.    I quickly setup the dynamic group so that all the users in the OU were added to the group.  Job done!  Obviously not else I wouldn’t be writing this 🙂

It turns out this OU contains the admin accounts for a group of system administrators.  Adding these users to the group is not going to be a lot of use because none of them have email accounts configured and even if they did, it’s likely these users will not be monitoring the mail boxes associated with their personal functional accounts.  What we really want is to add the personal accounts of these admin users to the group.  Fairly easy to do manually but remember this group needs to be dynamic.  When a new admin account is added to the OU I need to add the personal account to the group and I want to avoid the SD forgetting to do this.

My solution to this was to use a scheduled task script, actually an ARS scheduled task script to do this.  The benefit of the latter is that the task parameters are visible in the ARS MMC and can be modified without having to modify the script.  I have a separate post on how to get the task parameters and use them in a script.

Here are a few tips for writing scripts that I use:

Tip one: I always add a script version variable at the top of my script listing.  If I’m following my own best practice and creating a log file I can use this in my log file header.  $scriptversion = “1.0”

Tip two: put any debug variables at the top of the script so you can see them easily – in this instance it’s in a separate section a little further down because I call the Clean-Memory function and this wiped the debug variables.

Tip Three: before you write the script write the script file header and document the script logic in outline – this will help  you actually write the code and gather the requirements too.

Tip four: if it’s going to be a big script then using #region <section details> #endregion will allow you to quickly hide section of code when you are writing / debugging – you can even use the notes you just wrote in Tip Three

Tip five: Clean Up variables as a matter of course especially when working in an ISE.  I’ve been caught out many times by not doing this.  Between runs variables stay in memory which can mean your code works fine as you are debugging it but fails in production.

Tip six: When making bulk updates – as well as a log file always create a rollback file something that can be used to quickly reverse the changes made by your script. The log file is to assist you in debugging when things go wrong and also so you can check that the script is running properly and doing what it’s supposed to be doing.

Tip six might literally save your job one day so don’t forget it!

Tip Seven: when working at the command prompt, using the shorter command alias’ is great as it saves typing but in a production script don’t do it!  Use the full command line verbs – it will make the script easier to read and follow later on.  Who wants to remember that %  is the same as ForEach-Object.

it seems that Don Jones a Windows PowerShell MVP agrees with me

In classes, I tell my students to type whatever they want when they’re using the shell. After all, saving yourself typing saves you time. That’s what the shell is all about. However, I also tell them they should copy and paste a command into a script. It makes sense to use the full cmdlet name and the full parameter names. Doing so makes the eventual script easier to read

http://technet.microsoft.com/en-us/magazine/gg675931.aspx

In that same blog Don also recommends the use of splatting to make your code more readable

So the next time you’re placing a command in a script, consider splatting. If nothing else, it sounds really cool

Nip over to Dons’ blog linked above for more details on splatting but you will see I’ve made use of it in my script to dynamically update the group membership.

To keep the blog short I’ve split out the explanations of the function into other blogs and you can jump between them using the links below:

function Send-AlertEmail – sends an email alert to the script support team so they are aware that the script is not functioning as expected – uses splatting

function Clean-Memory – a simple one liner (split over a few lines for readability reasons) that I use to remove all the variables I set up in a script – this helps prevent debug errors as it makes sure you instantiate all the variables your script needs rather than using one that you previously instantiated in a previous run.  I find it best to make sure I’ve not got any lingering variables when I run my scripts so I purposely kill them off at the beginning of the script.  I modified this function recently so it can be called at the start of the script to store all of the existing variable and then again at the end to release them all.  As part of this I made use of the global variable scope so that the variable would persist during the script run.

Getting Variables from the ARS scheduled task.

In my environment we use the employeeID and employeeNumber attributes to link personal employee accounts to personal functional accounts (the admin accounts).  This allows me to search AD for a matching personal employee account with the same employeeID as the value in the admin accounts employeeNumber attribute.

As some of the admin accounts might have emails associated I could have just put them in the group but the design was to put the personal employee accounts.  I also allowed for the OU to contain other distribution lists.

I’ve added a download link for the code as well as shown it here because annoyingly when I post the code it adds a line number and when you cut an paste it into any editor you will spend ages deleting the numbers 😦

DynamicGroups.ps1

Since I did my initial post I revisited this script and decided to allow it even more flexibility and to add similar capabilities that the ARS Dynamic Group configuration allows.  I still need to add in the exclude options and I might even allow for multiple LDAP queries but for now one is enough don’t you think?

#===========================================================================
# Group-UpdateGroupsMembersBasedOnOU
$scriptversion = "2.0"
#===========================================================================
#
# This ActiveRoles Server Scheduled Task Script manages a groups 'shadow' membership
# i.e. it locates target users and if they are personal functional accounts
# determined by the employeeNumber attribute holding the employeeID of the
# personal employee account it adds the users personal employee account to the
# target group.
#
# The script will also add any personal functional accounts it finds as well as
# an optional list of explicit users.
#
# The script can also target only personal functional accounts if the
# -FunctionalAccountsOnly parameter is set to YES
#
# Version 3 will look at having exclude queries as with the ARS dynamic group functionality
#
# For each user object found in a specified OU(s) the script checks:
# if the user has an employeeID the script adds it to the group if it's not already a member
# if the user does not have an employeeID then the script checks:
# if the user has an employeeNumber set and then searches for a matching user based on the employeeNumber
# if it finds a match it adds the user to the group if it's not already a member
# if both searches above fail it checks if the object has an associated email address
# if it has it adds the object to the group if it's not already a member
#
# Version 1.0 : Initial Script Version
#
# Version 2.0 adds in LDAP query and group members
#
########################################################################################################
#
#region Helper Functions
function Clean-Memory {
 if ($Global:startupvariables) {
  # if the startup variable exists we assume the call is to clean up after a script run
  Get-Variable |
   Where-Object { $Global:startupVariables -notcontains $_.Name } |
   ForEach-Object {
    try { Remove-Variable -Name "$($_.Name)" -Force -Scope "global" -ErrorAction SilentlyContinue -WarningAction SilentlyContinue}
    catch { }
   }
  # now clean the startupVariables
  try {Remove-Variable -Name startupvariables  -Scope "Global" -Force -ErrorAction SilentlyContinue }
  catch { }
  # just in case this is an initial run after the script had failed in the last run lets set up the variable again
  New-Variable -name startupVariables -Force -Scope "Global" -value ( Get-Variable | ForEach-Object { $_.Name } )
 }
 else {
  # Store all the start up variables so you can clean up when the script finishes.
  New-Variable -name startupVariables -Force -Scope "Global" -value ( Get-Variable | ForEach-Object { $_.Name } )
 }
}
Clean-Memory # reset any variables - prevents command line polution from previous runs especially when working in an ISE
function Send-AlertEmail {
 param ( $BodyText,$subject,$exitMessage )
 # set alert defaults if script fails completely
 if ( ( $emailTo -eq "" ) -or ($emailTo -eq $null) ) {
  $emailTo = "ITSupport@DOMAIN-NAME.com"
 }
 if ( $debug -eq $true ) {
  $emailTo = "lee.andrews@MyDomain.com"
 }
 $mailAlertParameterDefaults = @{
  "To"         = $emailTo;
  "From"       = "ARS-Group-UpdateGroupsMembersBasedOnOU@clan8.com";
  "SmtpServer" = "smtp.clan8.com"
  "Body"       = $BodyText
  "Subject"    = $subject
 }
 Send-MailMessage @mailAlertParameterDefaults
 Clean-Memory
 throw $exitMessage
}
#endregion
#region initialise script variables
#region debug variables
# uncomment the required debug variables below when running a debug
$debug = $true
#$DebugPreference = "Continue"
$taskDN = "CN=Update Mail Distribution Group,CN=Scheduled Tasks,CN=Server Configuration,CN=Configuration"
#endregion
$DN = $null
$GroupToManage = $null
$targetOUs = $null
$emailTo = $null
$includeQuery = $null
$searchRoot = $null
$includeGroups = $null
$explicitMembers = $null
$OUObjects = @()
$usersToAdd = ""
$usersToRemove = ""
$personalEmployeeAccounts = @()
$UserOU = $null
$logFolder = "C:\Scripts\Identity Management\Logs\DynamicGroups"
$removedUsers = @()
$addedUsers = @()
$OperationReason = $null
$LogFileRetentionDays = 30
#endregion
#Region  Retrieve Scheduled Task Parameters
try {
 $Task.DirObj.GetInfo()
 $Task.DirObj.GetInfoEx(@("edsaParameters"),0)
 $parameters = $Task.DirObj.Get("edsaParameters")
 $DN = $Task.DirObj.DN
 $DebugPreference = "SilentlyContinue"
}
catch { }
if ( $DN -eq $null ) {
 $Task = Get-QADObject -Identity $TaskDN  -Proxy -IncludedProperties edsaParameters
 if ( $Task -ne $null ) {
  $parameters = $Task.edsaParameters
  $DN = $TaskDN
 } # if ( $Task -ne $null ) {
 else { throw "Unable to get TASK DN script cannot continue" }
} # if ( $DN -eq $null ) {
# now get the mandatory parameters
if ( $parameters -ne $null ) {
 foreach ( $parameter in $parameters ) {
  switch ( $($parameter.Substring( 1,$parameter.IndexOf(">")-1)) ) {
   "GroupToManage" {
    $groupToManage = $($parameter.Substring( $parameter.IndexOf(">")+1,$parameter.IndexOf("")+1)))
    if ( $groupToManage.Length -ge 1 ) {
     # we only support DOMAIN-NAME so check if the group is prefixed with DOMAIN-NAME
     # first check if there is a backslash in the group name
     if ( $groupToManage.indexOf("\") -gt 0 ) {
      # now check if it's DOMAIN-NAME if not bail out!
      if ( $groupToManage.indexOf("DOMAIN-NAME\",[System.StringComparison]::OrdinalIgnoreCase) -ne 0 ) {
       $groupToManage = $null
      }
     }
     else {
      # prefix the group name with DOMAIN-NAME if it was missing
      $groupToManage = "DOMAIN-NAME\$groupToManage"
     }
    }
    break
   } # "GroupToManage" {
   "UserOU" {
    $targetOUs  = $($parameter.Substring( $parameter.IndexOf(">")+1,$parameter.IndexOf("")+1)))
    if ( $targetOUs -ne "" ) {
     $targetOUs = $targetOUs.split(";")
    }
    break
   }
   "emailTo" {
    $emailTo  = $($parameter.Substring( $parameter.IndexOf(">")+1,$parameter.IndexOf("")+1)))
    if ( $emailTo -ne "" ) {
     $emailTo = $emailTo.split(";")
    }
    break
   } #  "emailTo" {
   "IncludeQuery" {
    $includeQuery  = $($parameter.Substring( $parameter.IndexOf(">")+1,$parameter.IndexOf("")+1)))
    break
   } #  "IncludeQuery" {
   "QueryScope" {
    $searchRoot  = $($parameter.Substring( $parameter.IndexOf(">")+1,$parameter.IndexOf("")+1)))
    break
   } #  "QueryScope" {
   "includeGroups" {
    $includeGroups  = $($parameter.Substring( $parameter.IndexOf(">")+1,$parameter.IndexOf("")+1)))
    if ( $includeGroups -ne "" ) {
     $includeGroups = $includeGroups.split(";")
    }
    break
   } #  "includeGroups" {
   "explicitMembers" {
    $explicitMembers  = $($parameter.Substring( $parameter.IndexOf(">")+1,$parameter.IndexOf("")+1)))
    if ( $explicitMembers -ne "" ) {
     $explicitMembers = $explicitMembers.split(";")
    }
    break
   } #  "includeGroups" {
   "OperationReason" {
    $OperationReason  = $($parameter.Substring( $parameter.IndexOf(">")+1,$parameter.IndexOf("")+1)))
    break
   } #  "OperationReason" {
   "LogFileRetentionDays" {
    $LogFileRetentionDays  = $($parameter.Substring( $parameter.IndexOf(">")+1,$parameter.IndexOf("")+1)))
    if ( $LogFileRetentionDays -eq "" ) { $LogFileRetentionDays = 30 }
    break
   }
  }  # switch ( $($parameter.Substring( 1,$parameter.IndexOf(">")-1)) ) {
 } # foreach ( $parameter in $parameters ) {
}
# now check we got the minimum No. of parameters
if (
    ( $groupToManage.length -ge 1     ) -and
    ( $emailTo.length -ge 1           ) -and
    ( !
     (
      ( $targetOUs.length -ge 1       ) -or
      ( $includeQuery.length -ge 1    ) -or
      ( $includeGroups.length -ge 1   ) -or
      ( $explicitMembers.length -ge 1 )
     )
    )
   )
{
 $alertParameters =@{
  "BodyText"    = "Missing one or more of the mandatory parameters, GroupToManage, emailTo or UserOU - NOTE the script only supports groups in the DOMAIN-NAME Domain"
  "subject"     = "Missing one or more of the mandatory parameters, GroupToManage, emailTo or UserOU"
  "exitMessage" = "Missing one or more of the mandatory parameters, GroupToManage, emailTo or UserOU"
 }
 Send-AlertEmail @alertParameters
} # if ( ( $groupToManage -eq $null) -or ( $targetOUs -eq $null ) ) {
if ( $OperationReason.length -lt 1 ) { $OperationReason = "$DN" }
#endregion
#Region get users to process
If ( $targetOUs.Length -ge 1 ) {
 foreach ( $OU in $targetOUs ) {
  if ( ($OU.Length -gt 3 ) -and ( $OU.substring(0,3) -eq "OU=" ) ) {
   $foundObjects = Get-QADObject -SearchRoot $OU -Proxy -IncludedProperties employeeNumber,employeeID,ProxyAddresses |
    where { ( ($_.gettype().name -eq "ArsUserObject") -or ($_.gettype().name -eq "ArsGroupObject")  ) }
   if ( $foundObjects -ne $null ) {  $OUObjects += $foundObjects }
  }
 } # foreach ( $OU in $targetOUs ) {
} # If ( $targetOUs.Length -ge 1 ) {
if ( $includeQuery.length -ge 1 ) {
 if ( $searchRoot.length -ge 1 ) {
  $foundObjects = Get-QADObject -SearchRoot $searchRoot -LdapFilter $includeQuery -Proxy -IncludedProperties employeeNumber,employeeID,ProxyAddresses
 }
 else {
  $searchRoot = "DC=AD,DC=COM"
  $foundObjects = Get-QADObject -SearchRoot $searchRoot -LdapFilter $includeQuery -Proxy -IncludedProperties employeeNumber,employeeID,ProxyAddresses
 }
 if ( $foundObjects -ne $null ) {  $OUObjects += $foundObjects }
}
if ( $includeGroups.Length -ge 1 ) {
 ForEach ( $group in $includeGroups ) {
  $foundObjects = Get-QADGroupMember -Identity "DOMAIN-NAME\$group" -Proxy -Type user -IncludedProperties employeeNumber,employeeID,ProxyAddresses
  if ( $foundObjects -ne $null ) {  $OUObjects += $foundObjects }
 }
}
#endregion
#Region get the groups current members
$currentGroupMembers = Get-QADGroupMember -Identity $groupToManage -Proxy | select -ExpandProperty NTAccountName
foreach ( $object in $OUObjects ) {
 # get the objects in the Target OUs
 $personalEmployeeAccount = $null
 # check if the
 # ( object is not mail enabled  AND employeeID is not present and it's a user object )
 # OR
 # ( the object has email but no employeeID but does have an employeeNumber )
 if (
     (
      ( ! ( $object.ProxyAddresses ) ) -and
      ( $object.employeeID -eq $null ) -and
      ( $object.gettype().name -eq "ArsUserObject" )
     ) -or
     (
      ( $object.ProxyAddresses ) -and
      ( $object.employeeID -eq $null ) -and
      ( $object.employeenumber -ne $null )
     )
    )
 {
  # see if the account has an employeeNumber otherwise there is nothing we can do
  if ( $object.employeeNumber -eq $null ) { continue }
  $personalEmployeeAccount = $(Get-QADUser -SearchAttributes @{employeeID=$object.employeeNumber} -SearchRoot "OU=User Accounts,DC=ad,DC=com" -Proxy).NTAccountName
 } # check if it a Personal Employee or Personal Functional account
 else {
  # if the object has a mail address then add it to the group
  if ( $object.ProxyAddresses ) {
   $personalEmployeeAccount = $object.NTAccountName
 }
}
if ( $personalEmployeeAccount -ne $null ) {
 if ( $personalEmployeeAccounts -contains $personalEmployeeAccount ) { continue } # make sure we do not add the same user twice!
  $personalEmployeeAccounts += $personalEmployeeAccount
  if ( $currentGroupMembers -notcontains $personalEmployeeAccount ) {
   $addedUsers += $personalEmployeeAccount
   if ( $usersToAdd -eq "" ) { $usersToAdd += """$personalEmployeeAccount""" }
   else { $usersToAdd += ",""$personalEmployeeAccount""" }
  } # if ( $currentGroupMembers -notcontains $personalEmployeeAccount ) {
 } # if ( $personalEmployeeAccount -ne $null ) {
} # Work out which users, if any, to add to the group
foreach ( $currentGroupMember in $currentGroupMembers ) {
 if ( $personalEmployeeAccounts -notcontains $currentGroupMember ) {
  $removedUsers += $currentGroupMember
  if ( $usersToRemove -eq "" ) { $usersToRemove += """$currentGroupMember""" }
  else { $usersToRemove += ",""$currentGroupMember""" }
 } # if ( $personalEmployeeAccounts -notcontains $currentGroupMember ) {
} # Work out which users, if any, to remove from the group
#endregion
#Region Optionally create a rollback log
if ( ( $usersToRemove.length -gt 0 ) -or ( $usersToAdd.length -gt 0 ) ) {
 $dateFormat = "yyyy-MM-dd-HH-mm"
 $unique = $(Get-Date -Format $dateFormat)
 $rollBackLogfile = "$logFolder\$($GroupToManage.Substring(0,$GroupToManage.indexOf("\")))-$($GroupToManage.Substring(15))-"+$unique+".csv"
 if ( ! ( Test-Path $logFolder ) ) {
  try { New-Item -Path $logFolder }
  catch {  }
 }
 if ( Test-Path $logFolder ) {
  Out-File -Encoding "UTF8" -FilePath $rollBackLogfile -InputObject "Domain,SamAccountName"
  ForEach ( $member in $currentGroupMembers ) {
   Out-File -Encoding "UTF8" -FilePath $rollBackLogfile -InputObject "$($member.subString(0,$member.indexOf("\"))),$($member.subString($member.indexOf("\")+1))" -Append
  }
 }
 else {
  $alertParameters =@{
   "BodyText"    = "Unable to create a rollback file script halted"
   "subject"     = "Unable to create a rollback file script halted"
   "exitMessage" = "Unable to create a rollback file script halted"
  }
  Send-AlertEmail @alertParameters
 }
}
#endregion
#region Add users to the group
$Error.Clear()
if ( $usersToAdd.length -gt 0 ) {
 $usersToAdd = "Set-QADGroup -Identity ""$groupToManage"" -Member @{append=@($usersToAdd)}"+' -Control @{OperationReason="' + $operationReason + '"}' + " -proxy"
 Invoke-Expression $usersToAdd
 if ( $Error.Count -gt 0 ) {
  $alertParameters =@{
   "BodyText"    = "Unable to add new members script halted"
   "subject"     = "Unable to add new members"
   "exitMessage" = "Unable to add new members"
  }
  Send-AlertEmail @alertParameters
 }
 $addedLogfile = "$logFolder\$($GroupToManage.Substring(0,$GroupToManage.indexOf("\")))-$($GroupToManage.Substring(15))-"+$unique+"-ADDED.csv"
 Out-File -Encoding "UTF8" -FilePath $addedLogfile -InputObject "Domain,SamAccountName"
 if ( Test-Path ( $addedLogfile ) ) {
  ForEach ( $member in $addedUsers ) {
   Out-File -Encoding "UTF8" -FilePath $addedLogfile -InputObject "$($member.subString(0,$member.indexOf("\"))),$($member.subString($member.indexOf("\")+1))" -Append
  }
 }
 else {
  $alertParameters =@{
   "BodyText"    = "Unable to create a Added file script halted"
   "subject"     = "Unable to create a Added file script halted"
   "exitMessage" = "Unable to create a Added file script halted"
  }
  Send-AlertEmail @alertParameters
 }
}
#endregion
#region Remove Users from the group
if ( $usersToRemove.length -gt 0 ) {
 $usersToRemove = "Set-QADGroup -Identity ""$groupToManage"" -Member @{delete=@($usersToRemove)}"+' -Control @{OperationReason="' + $operationReason + '"}' + " -proxy"
 Invoke-Expression $usersToRemove
 if ( $Error.Count -gt 0 ) {
  $alertParameters =@{
   "BodyText"    = "Unable to remove members script halted"
   "subject"     = "Unable to remove members"
   "exitMessage" = "Unable to remove members"
  }
  Send-AlertEmail @alertParameters
 }
 $removedLogfile = "$logFolder\$($GroupToManage.Substring(0,$GroupToManage.indexOf("\")))-$($GroupToManage.Substring(15))-"+$unique+"-REMOVED.csv"
 Out-File -Encoding "UTF8" -FilePath $removedLogfile -InputObject "Domain,SamAccountName"
 if ( Test-Path ( $removedLogfile ) ) {
  ForEach ( $member in $removedUsers ) {
   Out-File -Encoding "UTF8" -FilePath $removedLogfile -InputObject "$($member.subString(0,$member.indexOf("\"))),$($member.subString($member.indexOf("\")+1))" -Append
  }
 }
 else {
  $alertParameters =@{
   "BodyText"    = "Unable to create a Removed file script halted"
   "subject"     = "Unable to create a Removed file script halted"
   "exitMessage" = "Unable to create a Removed file script halted"
  }
  Send-AlertEmail @alertParameters
 }
}
#endregion
#region Clean Up
# clean up old log files...
$Now = Get-Date
$LastWrite = $Now.AddDays(-$LogFileRetentionDays)
$Files = Get-ChildItem $logFolder -include *.* -recurse | Where {$_.LastWriteTime -le "$LastWrite"}
if ($i) { if ($i -is [IDisposable]) {try {$p.Dispose() | Out-Null } catch { }}; Remove-Variable -Name i -ErrorAction SilentlyContinue | Out-Null }
$i = 0
if ($Files.count -gt 0) {
 foreach ($File in $Files) {
  $i++
  Remove-Item $File | Out-Null
 }
}
Clean-Memory
#endregion
# end script

Advertisements

One thought on “Dynamically manage group memberships

  1. […] Dynamically manage group memberships (clan8blog.wordpress.com) […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.