sync: auto-sync from HOWARD-HOME at 2026-04-22 21:40:31
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-04-22 21:40:31
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# Remove AD `howard` account (misspelled/orphan account, not used by anyone).
|
||||
# Captures pre-state to D:\Backups and confirms removal. AD Recycle Bin keeps
|
||||
# the object for 180 days so Restore-ADObject is available if needed.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Import-Module ActiveDirectory
|
||||
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd-HHmmss'
|
||||
$bd = "D:\Backups\howard-delete-$ts"
|
||||
New-Item -Path $bd -ItemType Directory -Force | Out-Null
|
||||
|
||||
try {
|
||||
$u = Get-ADUser -Identity howard -Properties *
|
||||
Write-Output 'Pre-delete state:'
|
||||
Write-Output " SAM: $($u.SamAccountName)"
|
||||
Write-Output " UPN: $($u.UserPrincipalName)"
|
||||
Write-Output " Display: $($u.DisplayName)"
|
||||
Write-Output " Description: $($u.Description)"
|
||||
Write-Output " mail: $($u.mail)"
|
||||
Write-Output " proxyAddrs: $(($u.proxyAddresses) -join '; ')"
|
||||
Write-Output " DN: $($u.DistinguishedName)"
|
||||
Write-Output " Enabled: $($u.Enabled)"
|
||||
Write-Output " PwdLastSet: $($u.PasswordLastSet)"
|
||||
Write-Output " Created: $($u.whenCreated)"
|
||||
Write-Output ''
|
||||
Write-Output ' Group memberships:'
|
||||
Get-ADPrincipalGroupMembership -Identity howard | ForEach-Object {
|
||||
Write-Output " - $($_.Name)"
|
||||
}
|
||||
|
||||
$u | Export-Clixml "$bd\howard-pre.xml"
|
||||
Write-Output ''
|
||||
Write-Output "Pre-state exported to: $bd\howard-pre.xml"
|
||||
Write-Output ''
|
||||
Write-Output 'Removing AD user howard...'
|
||||
Remove-ADUser -Identity howard -Confirm:$false
|
||||
Write-Output '[OK] Remove-ADUser returned without error.'
|
||||
|
||||
Write-Output ''
|
||||
Write-Output 'Verifying removal:'
|
||||
try {
|
||||
Get-ADUser -Identity howard -ErrorAction Stop | Out-Null
|
||||
Write-Output '[FAIL] Account still exists'
|
||||
exit 1
|
||||
} catch {
|
||||
Write-Output "[OK] Get-ADUser -Identity howard returns: $($_.Exception.Message.Split([char]10)[0])"
|
||||
}
|
||||
|
||||
Write-Output ''
|
||||
Write-Output 'Recycle Bin (180 day retention) entry for rollback:'
|
||||
$deleted = Get-ADObject -Filter { SamAccountName -eq 'howard' } -IncludeDeletedObjects -Properties whenChanged, isDeleted, ObjectGUID, lastKnownParent
|
||||
$deleted | Select-Object Name, ObjectGUID, isDeleted, whenChanged, lastKnownParent | Format-List | Out-String | Write-Output
|
||||
Write-Output 'Rollback command (within 180 days):'
|
||||
if ($deleted) {
|
||||
$guid = $deleted | Select-Object -First 1 -ExpandProperty ObjectGUID
|
||||
Write-Output " Restore-ADObject -Identity $guid"
|
||||
}
|
||||
} catch {
|
||||
Write-Output "FAIL: $_"
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
# ============================================================================
|
||||
# Entra Connect G1 Pre-flight AD Audit - CS-SERVER
|
||||
# ----------------------------------------------------------------------------
|
||||
# Purpose: collect everything needed to assess Entra Connect sync risk before
|
||||
# install. Strictly READ-ONLY. No writes, no renames, no state changes.
|
||||
#
|
||||
# Maps to Wave 0.5 Gate G1 in docs/cloud/user-account-rollout-plan.md.
|
||||
# Output is analyzed against the risk register at
|
||||
# docs/migration/entra-connect-risk-register-2026-04-22.md.
|
||||
# ============================================================================
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Import-Module ActiveDirectory -ErrorAction Stop
|
||||
|
||||
function Section($n) {
|
||||
Write-Output ''
|
||||
Write-Output ('=' * 76)
|
||||
Write-Output "== $n"
|
||||
Write-Output ('=' * 76)
|
||||
}
|
||||
|
||||
function Note($m) { Write-Output " $m" }
|
||||
|
||||
Write-Output "G1 AD Audit - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
|
||||
Write-Output "Host: $env:COMPUTERNAME"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '1. Forest / Domain / Schema'
|
||||
# ----------------------------------------------------------------------------
|
||||
$forest = Get-ADForest
|
||||
$domain = Get-ADDomain
|
||||
Write-Output "Forest FQDN: $($forest.Name)"
|
||||
Write-Output "Forest mode: $($forest.ForestMode)"
|
||||
Write-Output "Domain mode: $($domain.DomainMode)"
|
||||
Write-Output "Domain DN: $($domain.DistinguishedName)"
|
||||
Write-Output "NetBIOS: $($domain.NetBIOSName)"
|
||||
Write-Output "UPN suffixes (forest):"
|
||||
foreach ($s in $forest.UPNSuffixes) { Write-Output " - $s" }
|
||||
if ($forest.UPNSuffixes.Count -eq 0) { Write-Output ' (none configured beyond default cascades.local)' }
|
||||
|
||||
$schema = Get-ADObject (Get-ADRootDSE).schemaNamingContext -Property objectVersion
|
||||
Write-Output "Schema objectVersion: $($schema.objectVersion)"
|
||||
Write-Output ''
|
||||
Write-Output 'FSMO roles:'
|
||||
Write-Output " Schema Master: $($forest.SchemaMaster)"
|
||||
Write-Output " Domain Naming Master: $($forest.DomainNamingMaster)"
|
||||
Write-Output " PDC Emulator: $($domain.PDCEmulator)"
|
||||
Write-Output " RID Master: $($domain.RIDMaster)"
|
||||
Write-Output " Infrastructure Master: $($domain.InfrastructureMaster)"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '2. OU structure'
|
||||
# ----------------------------------------------------------------------------
|
||||
$ous = Get-ADOrganizationalUnit -Filter *
|
||||
Write-Output "Total OUs: $($ous.Count)"
|
||||
Write-Output ''
|
||||
foreach ($ou in $ous | Sort-Object DistinguishedName) {
|
||||
$uc = (Get-ADUser -Filter * -SearchBase $ou.DistinguishedName -SearchScope OneLevel | Measure-Object).Count
|
||||
$gc = (Get-ADGroup -Filter * -SearchBase $ou.DistinguishedName -SearchScope OneLevel | Measure-Object).Count
|
||||
$cc = (Get-ADComputer -Filter * -SearchBase $ou.DistinguishedName -SearchScope OneLevel | Measure-Object).Count
|
||||
Write-Output (" {0,-80} users={1} groups={2} computers={3}" -f $ou.DistinguishedName, $uc, $gc, $cc)
|
||||
}
|
||||
|
||||
# Users in CN=Users (default container, not an OU)
|
||||
$defaultUsersDN = "CN=Users,$($domain.DistinguishedName)"
|
||||
$usersInDefault = Get-ADUser -Filter * -SearchBase $defaultUsersDN -SearchScope OneLevel
|
||||
Write-Output ''
|
||||
Write-Output "Users in default CN=Users container (not ideal — should be in an OU for sync scope):"
|
||||
Write-Output " Count: $($usersInDefault.Count)"
|
||||
foreach ($u in $usersInDefault) {
|
||||
Write-Output " - $($u.SamAccountName) ($($u.Name))"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '3. All AD users - identity attributes for sync match'
|
||||
# ----------------------------------------------------------------------------
|
||||
$users = Get-ADUser -Filter * -Properties `
|
||||
SamAccountName, UserPrincipalName, DisplayName, GivenName, Surname, `
|
||||
Mail, proxyAddresses, Enabled, PasswordLastSet, PasswordNeverExpires, `
|
||||
PasswordNotRequired, LockedOut, whenCreated, whenChanged, `
|
||||
Department, Title, Office, Description, lastLogonTimestamp, `
|
||||
mS-DS-ConsistencyGuid, objectGUID, DistinguishedName
|
||||
|
||||
Write-Output "Total users: $($users.Count)"
|
||||
Write-Output "Enabled: $($users | Where-Object Enabled -eq $true | Measure-Object | Select-Object -ExpandProperty Count)"
|
||||
Write-Output "Disabled: $($users | Where-Object Enabled -eq $false | Measure-Object | Select-Object -ExpandProperty Count)"
|
||||
Write-Output ''
|
||||
|
||||
Write-Output 'Per-user identity dump (focus: anything that affects soft-match):'
|
||||
Write-Output ''
|
||||
Write-Output 'SAM | UPN | Mail | ProxyAddrs | PwdLastSet | PwdNeverExp | Enabled | OU'
|
||||
Write-Output '---'
|
||||
foreach ($u in $users | Sort-Object SamAccountName) {
|
||||
$ou = $u.DistinguishedName -replace '^CN=[^,]+,', ''
|
||||
$ou = $ou -replace ",DC=cascades,DC=local$", ''
|
||||
$px = if ($u.proxyAddresses) { ($u.proxyAddresses -join ';') } else { '' }
|
||||
$pls = if ($u.PasswordLastSet) { $u.PasswordLastSet.ToString('yyyy-MM-dd') } else { 'NULL' }
|
||||
Write-Output ("{0} | {1} | {2} | {3} | {4} | {5} | {6} | {7}" -f `
|
||||
$u.SamAccountName, $u.UserPrincipalName, $u.Mail, $px, $pls, $u.PasswordNeverExpires, $u.Enabled, $ou)
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '4. Soft-match risk scan - accounts likely to duplicate in Entra'
|
||||
# ----------------------------------------------------------------------------
|
||||
Write-Output '--- Users with NO proxyAddresses and NO mail (soft-match engine has nothing to work with):'
|
||||
foreach ($u in $users) {
|
||||
if (-not $u.Mail -and -not $u.proxyAddresses -and $u.Enabled) {
|
||||
Write-Output " - $($u.SamAccountName) (UPN: $($u.UserPrincipalName))"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ''
|
||||
Write-Output '--- Users whose UPN suffix is NOT cascadestucson.com (will mismatch M365 target unless renamed):'
|
||||
foreach ($u in $users) {
|
||||
if ($u.Enabled -and $u.UserPrincipalName -and $u.UserPrincipalName -notmatch '@cascadestucson\.com$') {
|
||||
Write-Output " - $($u.SamAccountName) UPN=$($u.UserPrincipalName)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ''
|
||||
Write-Output '--- Users whose SAM does not match their UPN prefix (name mismatch candidates):'
|
||||
foreach ($u in $users) {
|
||||
if ($u.Enabled -and $u.UserPrincipalName) {
|
||||
$upnPrefix = $u.UserPrincipalName -replace '@.*$', ''
|
||||
if ($u.SamAccountName -ne $upnPrefix) {
|
||||
Write-Output " - SAM='$($u.SamAccountName)' UPN='$($u.UserPrincipalName)'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ''
|
||||
Write-Output '--- Users with DisplayName different from Given+Surname (may cause display oddities post-sync):'
|
||||
foreach ($u in $users) {
|
||||
if ($u.Enabled -and $u.GivenName -and $u.Surname) {
|
||||
$expected = "$($u.GivenName) $($u.Surname)"
|
||||
if ($u.DisplayName -and $u.DisplayName -ne $expected) {
|
||||
Write-Output " - SAM=$($u.SamAccountName) display='$($u.DisplayName)' expected='$expected'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '5. Password hygiene (affects whether sync-derived sign-in will work)'
|
||||
# ----------------------------------------------------------------------------
|
||||
Write-Output '--- Enabled users with null PasswordLastSet (never set a password):'
|
||||
foreach ($u in $users) {
|
||||
if ($u.Enabled -and -not $u.PasswordLastSet) {
|
||||
Write-Output " - $($u.SamAccountName) whenCreated=$($u.whenCreated.ToString('yyyy-MM-dd'))"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ''
|
||||
Write-Output '--- Users with PasswordNotRequired=True:'
|
||||
foreach ($u in $users) {
|
||||
if ($u.PasswordNotRequired) {
|
||||
Write-Output " - $($u.SamAccountName) enabled=$($u.Enabled)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ''
|
||||
Write-Output '--- Users with PasswordNeverExpires=True:'
|
||||
foreach ($u in $users) {
|
||||
if ($u.PasswordNeverExpires -and $u.Enabled) {
|
||||
Write-Output " - $($u.SamAccountName) PwdLastSet=$($u.PasswordLastSet) Description='$($u.Description)'"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ''
|
||||
Write-Output '--- Currently locked-out accounts:'
|
||||
foreach ($u in $users) {
|
||||
if ($u.LockedOut) {
|
||||
Write-Output " - $($u.SamAccountName) enabled=$($u.Enabled)"
|
||||
}
|
||||
}
|
||||
|
||||
$krbtgt = Get-ADUser krbtgt -Properties PasswordLastSet
|
||||
$krbtgtAge = if ($krbtgt.PasswordLastSet) { ((Get-Date) - $krbtgt.PasswordLastSet).Days } else { '?' }
|
||||
Write-Output ''
|
||||
Write-Output "krbtgt password last set: $($krbtgt.PasswordLastSet) (age: $krbtgtAge days)"
|
||||
Write-Output ' (best practice: rotate every 180 days; > 180 is a known audit finding)'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '6. Role-based / shared accounts (should NOT sync)'
|
||||
# ----------------------------------------------------------------------------
|
||||
$roleLike = @('Receptionist','receptionist','Culinary','saleshare','directoryshare','Front Desk',
|
||||
'frontdesk','Accounting','accounting','Nurse','nurse','mcnurse','memcarenurse',
|
||||
'Security','security','HR','hr','medtech','Memcare')
|
||||
Write-Output 'Accounts whose SamAccountName looks role-based (should be EXCLUDED from sync scope):'
|
||||
foreach ($u in $users) {
|
||||
foreach ($pattern in $roleLike) {
|
||||
if ($u.SamAccountName -eq $pattern -or $u.SamAccountName -ieq $pattern) {
|
||||
Write-Output " - $($u.SamAccountName) enabled=$($u.Enabled) OU=$($u.DistinguishedName)"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ''
|
||||
Write-Output 'Service / built-in accounts (context - usually excluded from sync):'
|
||||
$builtin = @('Administrator','krbtgt','Guest','localadmin','sysadmin','QBDataServiceUser34','howard')
|
||||
foreach ($u in $users) {
|
||||
if ($u.SamAccountName -in $builtin) {
|
||||
Write-Output " - $($u.SamAccountName) enabled=$($u.Enabled)"
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '7. Likely-departed accounts still enabled (HIPAA termination risk)'
|
||||
# ----------------------------------------------------------------------------
|
||||
# Accounts enabled but no logon activity in >= 90 days are candidate departures
|
||||
$cutoff = (Get-Date).AddDays(-90)
|
||||
Write-Output "Enabled accounts with no logon activity in 90+ days:"
|
||||
foreach ($u in $users | Sort-Object lastLogonTimestamp) {
|
||||
if ($u.Enabled -and $u.SamAccountName -notin $builtin) {
|
||||
$ll = if ($u.lastLogonTimestamp) { [DateTime]::FromFileTime($u.lastLogonTimestamp) } else { $null }
|
||||
if (-not $ll -or $ll -lt $cutoff) {
|
||||
$llStr = if ($ll) { $ll.ToString('yyyy-MM-dd') } else { 'never' }
|
||||
Write-Output " - $($u.SamAccountName) lastLogon=$llStr"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '8. AD groups inventory'
|
||||
# ----------------------------------------------------------------------------
|
||||
$groups = Get-ADGroup -Filter * -Properties Members,Description,whenCreated,GroupScope,GroupCategory
|
||||
Write-Output "Total groups: $($groups.Count)"
|
||||
Write-Output ''
|
||||
Write-Output 'Group name | Scope | Category | Members | Created | Description'
|
||||
Write-Output '---'
|
||||
foreach ($g in $groups | Sort-Object Name) {
|
||||
$mc = if ($g.Members) { $g.Members.Count } else { 0 }
|
||||
Write-Output ("{0} | {1} | {2} | {3} | {4} | {5}" -f `
|
||||
$g.SamAccountName, $g.GroupScope, $g.GroupCategory, $mc, `
|
||||
$g.whenCreated.ToString('yyyy-MM-dd'), ($g.Description -replace '\r?\n',' '))
|
||||
}
|
||||
|
||||
Write-Output ''
|
||||
Write-Output 'Security groups our rollout plan assumes (checking existence):'
|
||||
$needed = @(
|
||||
'SG-External-Signin-Allowed','SG-Caregivers','SG-FrontDesk','SG-CourtesyPatrol',
|
||||
'SG-Drivers','SG-Management-RW','SG-Sales-RW','SG-Culinary-RW','SG-IT-RW',
|
||||
'SG-Receptionist-RW','SG-Directory-RW','SG-Server-RW','SG-Chat-RW','SG-Office-PHI-External',
|
||||
'SG-Office-PHI-Internal','SG-CA-BreakGlass'
|
||||
)
|
||||
foreach ($n in $needed) {
|
||||
try {
|
||||
$g = Get-ADGroup -Identity $n -ErrorAction Stop
|
||||
Write-Output " [EXISTS] $n"
|
||||
} catch {
|
||||
Write-Output " [MISSING] $n (needs creation)"
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '9. Computers (for sync scope decision)'
|
||||
# ----------------------------------------------------------------------------
|
||||
$computers = Get-ADComputer -Filter * -Properties OperatingSystem,Enabled,lastLogonTimestamp
|
||||
Write-Output "Total computer accounts: $($computers.Count)"
|
||||
Write-Output "Enabled: $(($computers | Where-Object Enabled -eq $true).Count)"
|
||||
Write-Output "Disabled: $(($computers | Where-Object Enabled -eq $false).Count)"
|
||||
Write-Output ''
|
||||
Write-Output 'Computer | OS | Enabled | LastLogon'
|
||||
Write-Output '---'
|
||||
foreach ($c in $computers | Sort-Object Name) {
|
||||
$ll = if ($c.lastLogonTimestamp) { [DateTime]::FromFileTime($c.lastLogonTimestamp).ToString('yyyy-MM-dd') } else { 'never' }
|
||||
Write-Output ("{0} | {1} | {2} | {3}" -f $c.Name, $c.OperatingSystem, $c.Enabled, $ll)
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '10. Existing Entra Connect / MSOL account check'
|
||||
# ----------------------------------------------------------------------------
|
||||
# Entra Connect creates MSOL_<hex> service accounts in AD. If any exist from a
|
||||
# prior install attempt, soft-match will behave unpredictably.
|
||||
$msol = Get-ADUser -Filter 'SamAccountName -like "MSOL_*"' -ErrorAction SilentlyContinue
|
||||
if ($msol) {
|
||||
Write-Output 'EXISTING MSOL accounts found (indicates prior Connect attempt or residue):'
|
||||
foreach ($m in $msol) {
|
||||
Write-Output " - $($m.SamAccountName) enabled=$($m.Enabled)"
|
||||
}
|
||||
} else {
|
||||
Write-Output '[OK] No MSOL_* accounts in AD (clean install target)'
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '11. Recycle Bin + deleted objects'
|
||||
# ----------------------------------------------------------------------------
|
||||
$rb = Get-ADOptionalFeature 'Recycle Bin Feature'
|
||||
Write-Output "AD Recycle Bin enabled: $([bool]$rb.EnabledScopes)"
|
||||
|
||||
$deleted = Get-ADObject -Filter 'isDeleted -eq $true' -IncludeDeletedObjects -ResultSetSize 10 -Properties whenCreated,lastKnownParent
|
||||
if ($deleted) {
|
||||
Write-Output "Deleted objects (sample of 10):"
|
||||
foreach ($d in $deleted) {
|
||||
Write-Output " - $($d.Name) deletedFrom='$($d.lastKnownParent)'"
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '12. DNS / DC connectivity (sync path)'
|
||||
# ----------------------------------------------------------------------------
|
||||
Write-Output '--- Key outbound hostnames (already verified clean in readiness check, re-verify):'
|
||||
foreach ($h in 'login.microsoftonline.com','login.windows.net','adminwebservice.microsoftonline.com') {
|
||||
try {
|
||||
$r = Resolve-DnsName $h -Type A -ErrorAction Stop
|
||||
Write-Output " [OK] $h -> $($r[0].IPAddress)"
|
||||
} catch {
|
||||
Write-Output " [FAIL] $h -> $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '13. DC health quick recheck'
|
||||
# ----------------------------------------------------------------------------
|
||||
# dcdiag already validated tonight but re-check in case the reboot shook anything
|
||||
dcdiag /test:services /test:replications /test:fsmocheck /test:advertising /q 2>&1 |
|
||||
Where-Object { $_ -match 'failed|warning|error' } |
|
||||
ForEach-Object { Write-Output " $_" }
|
||||
Write-Output '(Any entries above mean a dcdiag warning/failure; otherwise silent = all pass.)'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section '14. NTP time sync (re-verify)'
|
||||
# ----------------------------------------------------------------------------
|
||||
$tsource = (w32tm /query /source 2>&1) -join ' '
|
||||
$tdrift = (w32tm /query /status 2>&1 | Select-String 'Last Successful Sync Time|ReferenceId|Stratum').ToString()
|
||||
Write-Output "Source: $tsource"
|
||||
Write-Output $tdrift
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
Section 'Done'
|
||||
# ----------------------------------------------------------------------------
|
||||
Write-Output "Completed at $(Get-Date)"
|
||||
357
clients/cascades-tucson/docs/migration/scripts/g1-ad-hygiene.ps1
Normal file
357
clients/cascades-tucson/docs/migration/scripts/g1-ad-hygiene.ps1
Normal file
@@ -0,0 +1,357 @@
|
||||
# ============================================================================
|
||||
# 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:<primary> + smtp:<aliases>
|
||||
$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) { '<empty>' } 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=<empty> -> $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')"
|
||||
Reference in New Issue
Block a user