Providers/AD/Public/New-ADTestSecurityGroups.ps1
|
function New-ADTestSecurityGroups { <# .EXTERNALHELP TestEnvironment-Help.xml .SYNOPSIS Creates Active Directory test security groups from CSV data #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Colour-coded console progress is intentional; results are returned as objects.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Exported name; renaming it would break callers.')] [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [OutputType([PSCustomObject])] param( [switch]$SkipMemberAssignment, [switch]$PassThru ) begin { $correlationId = [System.Guid]::NewGuid() Write-Verbose "Starting New-ADTestSecurityGroups - CorrelationId: $correlationId" # Get data paths $dataPath = Get-ADTestDataPath $groupsCSV = Join-Path $dataPath "ADSecurityGroups.csv" # Verify prerequisites if (-not (Test-Path $groupsCSV)) { throw "ADSecurityGroups.csv not found at: $groupsCSV" } # Get domain information $domain = Get-ADTestDomain # Every lookup by name in this step is scoped here. A seeded group is found, nested # and owned only within the seed's own tree, so an object elsewhere in the domain that # shares a name is never read as ours. $seedRoot = "OU=$($script:ADTestRootName),$($domain.DomainDN)" $seedPrefix = (Get-ADTestSeedMarker).Prefix # Counters $script:GroupsCreated = 0 $script:GroupsSkipped = 0 $script:MembersAdded = 0 $script:GroupsNested = 0 $script:Errors = @() # Job tracking for batch processing } process { try { Write-TestMessage -Message "Creating Active Directory Test Security Groups" -Type Header Write-TestMessage -Message "Loading security group data from CSV..." -Type Info # Import group data $groups = Import-Csv $groupsCSV Write-Verbose "Loaded $($groups.Count) groups from CSV" $totalGroups = $groups.Count $currentGroup = 0 Write-TestMessage -Message "Processing $totalGroups security groups..." -Type Info foreach ($group in $groups) { $currentGroup++ $percentComplete = ($currentGroup / $totalGroups) * 80 # Reserve 20% for member assignment $progressParams = @{ Activity = 'Creating Security Groups' Status = "Processing $($group.GroupName)" PercentComplete = $percentComplete } Write-Progress @progressParams try { # Skip if group already exists $groupFilter = "Name -eq '$((Get-ADTestSeedMarker).Prefix)$($group.GroupName)'" $existingGroup = Get-ADGroup -Filter $groupFilter -SearchBase $seedRoot -ErrorAction SilentlyContinue if ($existingGroup) { Write-Verbose "Group $($group.GroupName) already exists, skipping" $script:GroupsSkipped++ continue } # Determine OU path based on category $ouPath = switch ($group.Category) { 'Administrative' { "OU=Administrative,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } 'Access Control' { "OU=Resource,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } 'Departmental' { "OU=Department,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } 'Device Access' { "OU=Device,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } 'Application Access' { "OU=Resource,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } 'Employment Type' { "OU=Role,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } 'Geographic' { "OU=Location,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } 'Management Level' { "OU=Role,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } 'Organizational' { "OU=Role,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } 'Physical Access' { "OU=Resource,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } 'Resource Access' { "OU=Resource,OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } default { "OU=Groups,OU=$($script:ADTestRootName),$($domain.DomainDN)" } } # Verify OU exists, skip group creation if not found try { Get-ADOrganizationalUnit -Identity $ouPath -ErrorAction Stop | Out-Null } catch { Write-Warning ("OU not found: $ouPath. Skipping group creation " + "for $($group.GroupName). Please ensure OU structure is " + 'created first.') $script:GroupsSkipped++ continue } # Map group scope $groupScope = switch ($group.GroupScope) { 'DomainLocal' { 'DomainLocal' } 'Global' { 'Global' } 'Universal' { 'Universal' } default { 'Global' } } # The seed prefix and the shared description tag. Groups take the prefix on # both the name and the account name: a group's sAMAccountName is not capped # at 20 the way a user's or a computer's is, verified against a live domain # at 33 characters. $seed = Get-ADTestSeedMarker $prefixedName = '{0}{1}' -f $seed.Prefix, $group.GroupName $groupParams = @{ Name = $prefixedName SamAccountName = $prefixedName GroupCategory = 'Security' GroupScope = $groupScope Path = $ouPath # Description stays exactly what the CSV says. The seed tag lives in # adminDescription instead - see the OtherAttributes note below. Description = $group.Description OtherAttributes = @{ adminDescription = $seed.Tag } } # mail and info, where the CSV supplies them. The Mail column carries the # local part only and the domain is appended here - the same convention # New-ADTestUser already uses, which keeps the shipped data free of any # particular organisation's domain name. # Added to the hashtable already holding adminDescription rather than # replacing it. Assigning a fresh one here is what silently dropped the seed # tag from the 59 groups whose CSV row supplies a Mail or Info value - they # were created, correctly named and untagged, and only a per-class count # showed it. if ($group.Mail) { $groupParams['OtherAttributes']['mail'] = "$($group.Mail)@$($domain.DNSName)" } if ($group.Info) { $groupParams['OtherAttributes']['info'] = $group.Info } # ManagedBy holds a display name; the directory stores a distinguished # name. New-ADEnvironment creates users before groups, so the owner # exists by the time this runs. A leading CN= is tolerated because the # sibling CSVs write owners that way. The search is scoped to the seed # OU, so a real account of the same name is never made a seeded group's # owner. if ($group.ManagedBy) { $ownerName = $group.ManagedBy -replace '^CN=', '' $ownerFilter = "Name -eq '$($ownerName.Replace("'", "''"))'" $owner = Get-ADUser -Filter $ownerFilter -SearchBase $seedRoot -ErrorAction SilentlyContinue if ($owner) { $groupParams['ManagedBy'] = $owner.DistinguishedName } else { Write-Warning ("Owner '$ownerName' not found for " + "$($group.GroupName); ManagedBy left empty.") } } # Create security group if ($PSCmdlet.ShouldProcess($group.GroupName, "Create AD Security Group")) { Write-Verbose "Creating security group: $($group.GroupName) in $ouPath" New-ADGroup @groupParams $script:GroupsCreated++ } else { $wouldCreate = 'Would create security group: ' + "$($group.GroupName) in $ouPath" Write-Host $wouldCreate -ForegroundColor Green } } catch { Write-Error "Failed to create group $($group.GroupName): $($_.Exception.Message)" $script:Errors += "Group creation error for $($group.GroupName): $($_.Exception.Message)" } } # Second pass: group-into-group nesting from the MemberOfGroup column. # # Deliberately after the whole creation loop, because a group can be nested into # one that appears later in the file. Sequential rather than batched: it is a few # dozen writes, so it does not need the job machinery the user membership below # does, and keeping it in-process makes a nesting failure easy to attribute. # # Scope rules are the CSV's responsibility - Global may nest into any scope, # Universal into Universal or DomainLocal, DomainLocal into DomainLocal only. An # illegal pairing surfaces here as a per-row error rather than stopping the run. if (-not $SkipMemberAssignment -and -not $WhatIfPreference) { $nestingRow = @($groups | Where-Object { $_.MemberOfGroup }) if ($nestingRow.Count -gt 0) { $nestingMessage = "Nesting $($nestingRow.Count) group(s) into their " + 'parent groups...' Write-TestMessage -Message $nestingMessage -Type Info # Both sides of a nesting pair are looked up by their directory name, which # carries the prefix. Comparing the bare CSV name reported all 38 pairs as # "child not found" while the groups were sitting right there. $nestPrefix = (Get-ADTestSeedMarker).Prefix foreach ($row in $nestingRow) { $childFilter = "Name -eq '$($nestPrefix)$($row.GroupName.Replace("'", "''"))'" $child = Get-ADGroup -Filter $childFilter -SearchBase $seedRoot -ErrorAction SilentlyContinue if (-not $child) { $script:Errors += "Nesting skipped: child '$($row.GroupName)' not found" continue } foreach ($parentName in ($row.MemberOfGroup -split ';')) { $parentName = $parentName.Trim() if (-not $parentName) { continue } $parentFilter = "Name -eq '$($nestPrefix)$($parentName.Replace("'", "''"))'" $parent = Get-ADGroup -Filter $parentFilter -SearchBase $seedRoot -ErrorAction SilentlyContinue if (-not $parent) { $script:Errors += "Nesting skipped: parent '$parentName' " + "not found for '$($row.GroupName)'" continue } try { Add-ADGroupMember -Identity $parent.DistinguishedName ` -Members $child.DistinguishedName -ErrorAction Stop $script:GroupsNested++ } catch { if ($_.Exception.Message -notlike '*already a member*') { $script:Errors += "Nesting error: '$($row.GroupName)' " + "into '$parentName': $($_.Exception.Message)" } } } } Write-TestMessage -Message "Group nesting completed" -Type Success } } # Third pass: membership, one rule per row, resolved inside the seed OU. # # The rules live in the CSV (MemberFilter, MemberSource, MemberLimit) and are read by # Resolve-ADTestGroupMember, so adding a group is a data change and the rules can be # unit-tested. They used to be a regex switch on group names inside a background # job, searching the whole domain, which is how twelve real accounts came to be in # seeded groups and how a group matching two rules counted its members twice. if (-not $SkipMemberAssignment -and -not $WhatIfPreference) { $groupsForMembership = @($groups | Where-Object { [bool]::Parse($_.AutoAssignment) -eq $true }) if ($groupsForMembership.Count -gt 0) { Write-TestMessage -Message "Processing membership for $($groupsForMembership.Count) groups..." -Type Info $index = 0 foreach ($group in $groupsForMembership) { $index++ Write-Progress -Activity 'Creating Security Groups' -Status "Membership: $($group.GroupName)" ` -PercentComplete (85 + (($index / $groupsForMembership.Count) * 15)) try { $adGroup = Get-ADGroup -Filter "Name -eq '$($seedPrefix)$($group.GroupName.Replace("'", "''"))'" ` -SearchBase $seedRoot -ErrorAction SilentlyContinue if (-not $adGroup) { $script:Errors += "Group not found: $($group.GroupName)" continue } $members = @(Resolve-ADTestGroupMember -Rule $group -SeedRoot $seedRoot) if ($members.Count -eq 0) { continue } try { Add-ADGroupMember -Identity $adGroup.DistinguishedName -Members @($members.DistinguishedName) -ErrorAction Stop $script:MembersAdded += $members.Count } catch { # One refused member fails the whole batch, so fall back to one at a # time. A member the group already holds is neither an error nor an # addition. Write-Verbose "Batch add to $($group.GroupName) failed ($($_.Exception.Message)); adding one at a time" foreach ($member in $members) { try { Add-ADGroupMember -Identity $adGroup.DistinguishedName -Members $member.DistinguishedName -ErrorAction Stop $script:MembersAdded++ } catch { if ($_.Exception.Message -notlike '*already a member*') { $script:Errors += "Failed to add $($member.SamAccountName) to $($group.GroupName): $($_.Exception.Message)" } } } } } catch { $script:Errors += "Membership error for $($group.GroupName): $($_.Exception.Message)" } } Write-TestMessage -Message "Membership assignment completed" -Type Success } } Write-Progress -Activity "Creating Security Groups" -Status "Complete" -PercentComplete 100 -Completed # Create summary $results = @{ CorrelationId = $correlationId TotalGroups = $totalGroups CreatedGroups = $script:GroupsCreated SkippedGroups = $script:GroupsSkipped MembersAdded = if ($SkipMemberAssignment) { 0 } else { $script:MembersAdded } GroupsNested = if ($SkipMemberAssignment) { 0 } else { $script:GroupsNested } Errors = $script:Errors } # Display summary Write-TestMessage -Message "Security Group Creation Summary" -Type Success Write-Host " Groups Created: $($results.CreatedGroups)" -ForegroundColor Green Write-Host " Groups Skipped: $($results.SkippedGroups)" -ForegroundColor Yellow if (-not $SkipMemberAssignment) { Write-Host " Members Added: $($results.MembersAdded)" -ForegroundColor Green Write-Host " Groups Nested: $($results.GroupsNested)" -ForegroundColor Green } if ($results.Errors.Count -gt 0) { Write-Host " Errors: $($results.Errors.Count)" -ForegroundColor Red $results.Errors | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } } if ($PassThru) { return [PSCustomObject]$results } } catch { Write-Error "Failed to create security groups: $($_.Exception.Message)" -ErrorAction Stop } } end { Write-Verbose "Completed New-ADTestSecurityGroups - CorrelationId: $correlationId" } } |