# ============================================================================
# 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)"
