# ============================================================================ # Entra Connect Gate G1 - AD Hygiene / Pre-sync Cleanup # ---------------------------------------------------------------------------- # Runs on CS-SERVER via GuruRMM. Idempotent. Each section reports what it # would do (dry-run) OR what it did (execute). Pre-state backups always run. # # USAGE: # Default invocation is DRY-RUN (no writes). Run with $doExecute=$true to # apply changes. # # Sections: # 0. Pre-state backup (always runs, no changes) # 1. OU=Excluded-From-Sync (create; move 4 role accounts) # 2. proxyAddresses populate (34 users - live data from M365 Graph) # 3. SG-* security groups (create 16 groups in OU=Groups) # 4. DisplayName cosmetics (3 fixes: Crystal, howard, Cathy) # 5. Summary (counts, rollback path) # # Maps to risk register: docs/migration/entra-connect-risk-register-2026-04-22.md # ============================================================================ # Toggle at top of script so it's obvious in GuruRMM command preview $doExecute = $false # CHANGE TO $true TO APPLY $ErrorActionPreference = 'Continue' Import-Module ActiveDirectory -ErrorAction Stop $ts = Get-Date -Format 'yyyy-MM-dd-HHmmss' $backupDir = "D:\Backups\g1-hygiene-$ts" $mode = if ($doExecute) { 'EXECUTE' } else { 'DRY-RUN (no changes)' } function Section($n) { Write-Output '' Write-Output ('=' * 76) Write-Output "== $n" Write-Output ('=' * 76) } function Log($level, $msg) { $prefix = switch ($level) { 'WOULD' { '[WOULD]' } 'DID' { '[DID] ' } 'OK' { '[OK] ' } 'SKIP' { '[SKIP] ' } 'WARN' { '[WARN] ' } 'FAIL' { '[FAIL] ' } default { ' ' } } Write-Output "$prefix $msg" } Write-Output "G1 AD Hygiene - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')" Write-Output "Host: $env:COMPUTERNAME" Write-Output "Mode: $mode" Write-Output "Backup dir: $backupDir" # Counters $script:stats = @{ created=0; moved=0; updated=0; skipped=0; errors=0 } # ---------------------------------------------------------------------------- Section '0. Pre-state backup (always runs)' # ---------------------------------------------------------------------------- try { if (-not (Test-Path $backupDir)) { New-Item -Path $backupDir -ItemType Directory -Force | Out-Null } # Full user dump with relevant attributes for diff-later Get-ADUser -Filter * -Properties SamAccountName,UserPrincipalName,DisplayName,GivenName,Surname,` Mail,proxyAddresses,Enabled,PasswordLastSet,PasswordNeverExpires,DistinguishedName,whenCreated,whenChanged | Select-Object SamAccountName,UserPrincipalName,DisplayName,GivenName,Surname,Mail, @{Name='ProxyAddresses';Expression={($_.proxyAddresses -join ';')}}, Enabled,PasswordLastSet,PasswordNeverExpires,DistinguishedName | Export-Csv "$backupDir\users-pre.csv" -NoTypeInformation -Encoding UTF8 Log 'OK' "Exported users-pre.csv" Get-ADGroup -Filter * -Properties Description,GroupScope,GroupCategory,Members | Select-Object SamAccountName,Name,GroupScope,GroupCategory,Description, @{Name='MemberCount';Expression={$_.Members.Count}}, DistinguishedName | Export-Csv "$backupDir\groups-pre.csv" -NoTypeInformation -Encoding UTF8 Log 'OK' "Exported groups-pre.csv" Get-ADOrganizationalUnit -Filter * | Select-Object Name,DistinguishedName | Export-Csv "$backupDir\ous-pre.csv" -NoTypeInformation -Encoding UTF8 Log 'OK' "Exported ous-pre.csv" Write-Output "" Log 'OK' "Pre-state saved at $backupDir" Write-Output "Rollback commands (if needed after execute):" Write-Output " - proxyAddresses: Set-ADUser from users-pre.csv column ProxyAddresses" Write-Output " - OU moves: Move-ADObject back to old DistinguishedName" Write-Output " - Groups created today: Remove-ADGroup (safe since memberless)" } catch { Log 'FAIL' "Pre-state backup failed: $_" $script:stats.errors++ } # ---------------------------------------------------------------------------- Section '1. OU=Excluded-From-Sync + move 4 role accounts' # ---------------------------------------------------------------------------- $domainDN = (Get-ADDomain).DistinguishedName $excludedOU = "OU=Excluded-From-Sync,$domainDN" # Create OU $existing = $null try { $existing = Get-ADOrganizationalUnit -Identity $excludedOU -ErrorAction Stop } catch {} if ($existing) { Log 'SKIP' "OU=Excluded-From-Sync already exists" } else { if ($doExecute) { try { New-ADOrganizationalUnit -Name 'Excluded-From-Sync' -Path $domainDN ` -Description 'Accounts scoped OUT of Entra Connect sync. Do not move staff users here.' ` -ProtectedFromAccidentalDeletion $true Log 'DID' "Created OU=Excluded-From-Sync" $script:stats.created++ } catch { Log 'FAIL' "New-ADOrganizationalUnit failed: $_" $script:stats.errors++ } } else { Log 'WOULD' "Create OU=Excluded-From-Sync (ProtectedFromAccidentalDeletion=true)" $script:stats.created++ } } # Move role accounts $roleAccounts = @('Culinary','Receptionist','saleshare','directoryshare') foreach ($sam in $roleAccounts) { try { $u = Get-ADUser -Identity $sam -Properties DistinguishedName -ErrorAction Stop $currentOU = ($u.DistinguishedName -split ',', 2)[1] if ($currentOU -eq $excludedOU) { Log 'SKIP' "$sam already in Excluded-From-Sync" continue } if ($doExecute) { Move-ADObject -Identity $u.DistinguishedName -TargetPath $excludedOU Log 'DID' "Moved ${sam}: $currentOU -> $excludedOU" $script:stats.moved++ } else { Log 'WOULD' "Move $sam from $currentOU to $excludedOU" $script:stats.moved++ } } catch { Log 'WARN' "Cannot resolve $sam - $_" } } # ---------------------------------------------------------------------------- Section '2. Populate proxyAddresses (34 users - live data from M365 Graph 2026-04-22)' # ---------------------------------------------------------------------------- # Mapping derived from live Graph API pull. Primary SMTP uses uppercase 'SMTP:' # prefix (Exchange convention for primary). Aliases use lowercase 'smtp:'. $proxyMap = @{ # 24 users with existing M365 mailboxes 'Allison.Reibschied' = @('Allison.Reibschied@cascadestucson.com') 'Alyssa.Brooks' = @('alyssa.brooks@cascadestucson.com') 'Ashley.Jensen' = @('ashley.jensen@cascadestucson.com','ashley.jenson@cascadestucson.com') 'britney.thompson' = @('Britney.Thompson@cascadestucson.com') 'Christina.DuPras' = @('christina.dupras@cascadestucson.com') 'Christine.Nyanzunda' = @('christine.nyanzunda@cascadestucson.com') 'Crystal.Rodriguez' = @('crystal.rodriguez@cascadestucson.com','crystal.suszek@cascadestucson.com') 'howard' = @('dax.howard@cascadestucson.com','cara.lespron@cascadestucson.com') 'JD.Martin' = @('jd.martin@cascadestucson.com') 'John.Trozzi' = @('john.trozzi@cascadestucson.com') 'karen.rossini' = @('karen.rossini@cascadestucson.com') 'lauren.hasselman' = @('lauren.hasselman@cascadestucson.com') 'Lois.Lane' = @('lois.lane@cascadestucson.com') 'Lupe.Sanchez' = @('lupe.sanchez@cascadestucson.com') 'Matt.Brooks' = @('matthew.brooks@cascadestucson.com') # soft-match bridge (M365 UPN is matthew.brooks) 'Megan.Hiatt' = @('megan.hiatt@cascadestucson.com') 'Meredith.Kuhn' = @('meredith.kuhn@cascadestucson.com') 'Ramon.Castaneda' = @('ramon.castaneda@cascadestucson.com','ramon.castanada@cascadestucson.com','ramon.casteneda@cascadestucson.com') 'Sharon.Edwards' = @('sharon.edwards@cascadestucson.com') 'Shelby.Trozzi' = @('Shelby.Trozzi@cascadestucson.com') 'Susan.Hicks' = @('susan.hicks@cascadestucson.com') 'sysadmin' = @('sysadmin@cascadestucson.com') 'Tamra.Matthews' = @('tamra.matthews@cascadestucson.com','tamra.johnson@cascadestucson.com') 'Veronica.Feller' = @('veronica.feller@cascadestucson.com') # 10 AD-only users (no M365 mailbox yet; reserve canonical SMTP for when they get licensed) 'Cathy.Kingston' = @('cathy.kingston@cascadestucson.com') 'Christopher.Holick' = @('christopher.holick@cascadestucson.com') 'Julian.Crim' = @('julian.crim@cascadestucson.com') 'Kyla.QuickTiffany' = @('kyla.quicktiffany@cascadestucson.com') 'Michelle.Shestko' = @('michelle.shestko@cascadestucson.com') 'Ray.Rai' = @('ray.rai@cascadestucson.com') 'Richard.Adams' = @('richard.adams@cascadestucson.com') 'Sebastian.Leon' = @('sebastian.leon@cascadestucson.com') 'Sheldon.Gardfrey' = @('sheldon.gardfrey@cascadestucson.com') 'Shontiel.Nunn' = @('shontiel.nunn@cascadestucson.com') } foreach ($sam in ($proxyMap.Keys | Sort-Object)) { try { $u = Get-ADUser -Identity $sam -Properties proxyAddresses,mail -ErrorAction Stop } catch { Log 'WARN' "AD user '$sam' not found - $($_.Exception.Message.Split([char]10)[0])" continue } # Build the target proxyAddresses value: SMTP: + smtp: $primary = $proxyMap[$sam][0] $aliases = @($proxyMap[$sam] | Select-Object -Skip 1) $targetProxies = @("SMTP:$primary") + ($aliases | ForEach-Object { "smtp:$_" }) $targetMail = $primary $currentProxies = @($u.proxyAddresses) $currentSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($p in $currentProxies) { [void]$currentSet.Add($p) } $targetSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($p in $targetProxies) { [void]$targetSet.Add($p) } if ($currentSet.SetEquals($targetSet) -and $u.mail -eq $targetMail) { Log 'SKIP' "$sam proxyAddresses already current" continue } $currentStr = if ($currentProxies.Count -eq 0) { '' } else { $currentProxies -join '; ' } $targetStr = $targetProxies -join '; ' if ($doExecute) { try { Set-ADUser -Identity $sam -Replace @{ proxyAddresses = $targetProxies mail = $targetMail } -ErrorAction Stop Log 'DID' "$sam" Write-Output " before: $currentStr" Write-Output " after: $targetStr" Write-Output " mail=$targetMail" $script:stats.updated++ } catch { Log 'FAIL' "$sam Set-ADUser failed: $_" $script:stats.errors++ } } else { Log 'WOULD' "$sam" Write-Output " before: $currentStr" Write-Output " after: $targetStr" Write-Output " mail= -> $targetMail" $script:stats.updated++ } } # ---------------------------------------------------------------------------- Section '3. Create 16 SG-* security groups (CA / file-share / break-glass)' # ---------------------------------------------------------------------------- $groupsOU = "OU=Groups,$domainDN" # Verify Groups OU exists try { Get-ADOrganizationalUnit -Identity $groupsOU -ErrorAction Stop | Out-Null } catch { Log 'FAIL' "OU=Groups does not exist at $groupsOU - cannot create groups" $script:stats.errors++ } $groupsToCreate = @( @{Name='SG-External-Signin-Allowed'; Desc='Members may sign in from outside Cascades building (CA policy target).'}, @{Name='SG-Caregivers'; Desc='All shift-work caregivers. CA policy target for shared-phone mobile policy.'}, @{Name='SG-FrontDesk'; Desc='Front desk receptionists sharing reception PCs.'}, @{Name='SG-CourtesyPatrol'; Desc='Courtesy patrol staff.'}, @{Name='SG-Drivers'; Desc='Transportation drivers (AD accounts being disabled 2026-04-22 - group retained for history).'}, @{Name='SG-Management-RW'; Desc='Read/write on \\CS-SERVER\Management file share (Phase 4).'}, @{Name='SG-Sales-RW'; Desc='Read/write on \\CS-SERVER\SalesDept file share (Phase 4).'}, @{Name='SG-Culinary-RW'; Desc='Read/write on \\CS-SERVER\Culinary file share (Phase 4).'}, @{Name='SG-IT-RW'; Desc='Read/write on \\CS-SERVER\IT file share (Phase 4).'}, @{Name='SG-Receptionist-RW'; Desc='Read/write on \\CS-SERVER\Receptionist file share (Phase 4).'}, @{Name='SG-Directory-RW'; Desc='Read/write on \\CS-SERVER\directoryshare file share (Phase 4).'}, @{Name='SG-Server-RW'; Desc='Read/write on \\CS-SERVER\Server share (IT admin, Phase 4).'}, @{Name='SG-Chat-RW'; Desc='Read/write on \\CS-SERVER\chat file share (Phase 4).'}, @{Name='SG-Office-PHI-External'; Desc='Office PHI staff with external sign-in permission (CA policy).'}, @{Name='SG-Office-PHI-Internal'; Desc='Office PHI staff limited to in-building sign-in (CA policy).'}, @{Name='SG-CA-BreakGlass'; Desc='Break-glass accounts excluded from all Conditional Access policies.'} ) foreach ($g in $groupsToCreate) { try { $existing = Get-ADGroup -Identity $g.Name -ErrorAction Stop Log 'SKIP' "$($g.Name) already exists" continue } catch { # group missing - proceed to create } if ($doExecute) { try { New-ADGroup -Name $g.Name -SamAccountName $g.Name -GroupCategory Security -GroupScope Global ` -Path $groupsOU -Description $g.Desc Log 'DID' "Created $($g.Name)" $script:stats.created++ } catch { Log 'FAIL' "Create $($g.Name) failed: $_" $script:stats.errors++ } } else { Log 'WOULD' "Create $($g.Name) (Global Security) in $groupsOU" Write-Output " desc: $($g.Desc)" $script:stats.created++ } } # ---------------------------------------------------------------------------- Section '4. DisplayName cosmetic fixes (3 users)' # ---------------------------------------------------------------------------- $displayFixes = @( @{SAM='Crystal.Rodriguez'; Current='Crystal Rodriguez'; Target='Crystal Rodriguez'} @{SAM='howard'; Current='howard'; Target='Howard Dax'} @{SAM='Cathy.Kingston'; Current='Cathy.Kingston'; Target='Cathy Kingston'} ) foreach ($d in $displayFixes) { try { $u = Get-ADUser -Identity $d.SAM -Properties DisplayName -ErrorAction Stop if ($u.DisplayName -eq $d.Target) { Log 'SKIP' "$($d.SAM) DisplayName already '$($d.Target)'" continue } if ($doExecute) { Set-ADUser -Identity $d.SAM -DisplayName $d.Target Log 'DID' "$($d.SAM) DisplayName: '$($u.DisplayName)' -> '$($d.Target)'" $script:stats.updated++ } else { Log 'WOULD' "$($d.SAM) DisplayName: '$($u.DisplayName)' -> '$($d.Target)'" $script:stats.updated++ } } catch { Log 'WARN' "$($d.SAM) lookup failed: $_" } } # ---------------------------------------------------------------------------- Section '5. Summary' # ---------------------------------------------------------------------------- Write-Output "Mode: $mode" Write-Output "Created: $($script:stats.created)" Write-Output "Moved: $($script:stats.moved)" Write-Output "Updated: $($script:stats.updated)" Write-Output "Skipped: $($script:stats.skipped)" Write-Output "Errors: $($script:stats.errors)" Write-Output "" Write-Output "Backup dir: $backupDir" Write-Output "" if (-not $doExecute) { Write-Output 'DRY-RUN complete. To execute:' Write-Output ' 1. Review the [WOULD] lines above' Write-Output ' 2. Re-run this script with $doExecute = $true' Write-Output ' 3. Compare post-state vs pre-state CSVs in the backup dir' } else { Write-Output 'EXECUTE complete. Recommended next steps:' Write-Output ' 1. Re-run in DRY-RUN to confirm 0 [WOULD] entries (idempotency check)' Write-Output " 2. Export users-post.csv for the audit trail (in $backupDir)" Write-Output ' 3. Proceed to Gate G2 (M365 role-account shared-mailbox conversion)' } Write-Output "" Write-Output "Completed at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"