Public/get-tenantreport.ps1

# get-tenantreport.ps1
# Produces a snapshot health report for a tenant.
# Useful as a first-look when picking up a new client, or as a weekly check.
#
# Covers:
# - Licence summary (assigned vs available)
# - Admin role holders
# - Users with no MFA
# - Disabled accounts still holding licences
# - Shared mailboxes with licences (usually unnecessary cost)
# - Guest account count
# - Last AD Connect sync (if hybrid)
# - M365 service health status
#
# Requires: Graph (User.Read.All, Directory.Read.All, Organization.Read.All,
# ServiceHealth.Read.All) + Exchange Online

if (-not (Get-ConnectionInformation)) { Connect-ExchangeOnline }
if (-not (Get-MgContext)) {
    Connect-MgGraph -Scopes "User.Read.All","Directory.Read.All","Organization.Read.All","ServiceHealth.Read.All","RoleManagement.Read.Directory","UserAuthenticationMethod.Read.All" -ContextScope Process
}

$report = [System.Collections.Generic.List[string]]::new()
$issues = [System.Collections.Generic.List[PSCustomObject]]::new()

function Section($title) {
    $line = "`n" + ("=" * 60) + "`n $title`n" + ("=" * 60)
    Write-Host $line -ForegroundColor Cyan
    $report.Add($line)
}

function Row($label, $value, $flag = $false) {
    $colour = if ($flag) { "Yellow" } else { "White" }
    $line = " {0,-40} {1}" -f $label, $value
    Write-Host $line -ForegroundColor $colour
    $report.Add($line)
    if ($flag) {
        $issues.Add([PSCustomObject]@{ Finding = $label; Value = $value })
    }
}

# --- Organisation info ---
Section "Tenant Overview"
$org = Get-MgOrganization
Row "Tenant Name"     $org.DisplayName
Row "Tenant ID"       $org.Id
Row "Default Domain"  ($org.VerifiedDomains | Where-Object { $_.IsDefault }).Name

# --- Licence summary ---
Section "Licence Summary"
$skus = Get-MgSubscribedSku
foreach ($sku in $skus) {
    $used      = $sku.ConsumedUnits
    $total     = $sku.PrepaidUnits.Enabled
    $available = $total - $used
    $flag      = $available -le 2
    Row "$($sku.SkuPartNumber)" "$used / $total used ($available available)" $flag
}

# --- Admin role holders ---
Section "Admin Role Holders"
$adminRoles = Get-MgDirectoryRole | Where-Object { $_.DisplayName -match "Admin|Global" }
foreach ($role in $adminRoles) {
    $members = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id
    if ($members.Count -gt 0) {
        $names = ($members | ForEach-Object {
            try { (Get-MgUser -UserId $_.Id -Property DisplayName).DisplayName } catch { $_.Id }
        }) -join ", "
        $flag = ($role.DisplayName -eq "Global Administrator" -and $members.Count -gt 3)
        Row $role.DisplayName "$($members.Count) member(s): $names" $flag
    }
}

# --- Users with no MFA registered ---
Section "Users Without MFA"
Write-Host " Checking MFA registration (this takes a while)..." -ForegroundColor DarkGray
$allUsers = Get-MgUser -All -Property "DisplayName,UserPrincipalName,AccountEnabled" |
    Where-Object { $_.AccountEnabled -eq $true -and $_.UserPrincipalName -notmatch "#EXT#" }

$noMfa = foreach ($u in $allUsers) {
    $methods = Get-MgUserAuthenticationMethod -UserId $u.Id
    # Filter out the default password method — everyone has that
    $realMethods = $methods | Where-Object { $_.OdataType -ne "#microsoft.graph.passwordAuthenticationMethod" }
    if ($realMethods.Count -eq 0) { $u.UserPrincipalName }
}

if ($noMfa.Count -eq 0) {
    Row "Users without MFA" "None — all good" $false
} else {
    Row "Users without MFA" "$($noMfa.Count) found" $true
    $noMfa | ForEach-Object { Row " $_" "" $false }
}

# --- Disabled accounts with licences (wasted spend) ---
Section "Disabled Accounts With Licences"
$disabledLicensed = Get-MgUser -All -Property "DisplayName,UserPrincipalName,AccountEnabled,AssignedLicenses" |
    Where-Object { $_.AccountEnabled -eq $false -and $_.AssignedLicenses.Count -gt 0 }

if ($disabledLicensed.Count -eq 0) {
    Row "Disabled + licensed accounts" "None found" $false
} else {
    Row "Disabled + licensed accounts" "$($disabledLicensed.Count) found — likely wasted spend" $true
    $disabledLicensed | ForEach-Object { Row " $($_.UserPrincipalName)" "$($_.AssignedLicenses.Count) licence(s)" $false }
}

# --- Shared mailboxes with licences ---
Section "Shared Mailboxes With Licences"
$sharedWithLicence = Get-Mailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited |
    ForEach-Object {
        $mbx = $_
        $mgUser = Get-MgUser -Filter "userPrincipalName eq '$($mbx.PrimarySmtpAddress)'" -Property AssignedLicenses -ErrorAction SilentlyContinue
        if ($mgUser -and $mgUser.AssignedLicenses.Count -gt 0) { $mbx.PrimarySmtpAddress }
    }

if ($sharedWithLicence.Count -eq 0) {
    Row "Licensed shared mailboxes" "None — no unnecessary spend" $false
} else {
    Row "Licensed shared mailboxes" "$($sharedWithLicence.Count) found" $true
    $sharedWithLicence | ForEach-Object { Row " $_" "Has licence assigned" $false }
}

# --- Guest accounts ---
Section "Guest Accounts"
$guests = Get-MgUser -All -Filter "userType eq 'Guest'" -Property "DisplayName,UserPrincipalName,CreatedDateTime"
Row "Total guest accounts" $guests.Count ($guests.Count -gt 20)

# --- AD Connect sync (hybrid only) ---
Section "AD Connect / Directory Sync"
try {
    $org2 = Get-MgOrganization -Property "OnPremisesLastSyncDateTime,OnPremisesSyncEnabled"
    if ($org2.OnPremisesSyncEnabled) {
        $lastSync = $org2.OnPremisesLastSyncDateTime
        $syncAge  = (New-TimeSpan -Start $lastSync -End (Get-Date)).TotalMinutes
        $flag     = $syncAge -gt 60
        Row "Directory sync enabled" "Yes"
        Row "Last sync" "$lastSync ($([math]::Round($syncAge)) mins ago)" $flag
    } else {
        Row "Directory sync" "Not enabled (cloud-only tenant)"
    }
} catch {
    Row "Directory sync check" "Unable to retrieve" $false
}

# --- M365 Service Health ---
Section "M365 Service Health"
try {
    $healthIssues = Get-MgServiceAnnouncementIssue -Filter "status ne 'resolved'" |
        Select-Object Title, Service, Status, StartDateTime
    if ($healthIssues.Count -eq 0) {
        Row "Active service issues" "None — all services healthy" $false
    } else {
        Row "Active service issues" "$($healthIssues.Count) open issue(s)" $true
        $healthIssues | ForEach-Object { Row " [$($_.Service)] $($_.Title)" $_.Status $false }
    }
} catch {
    Row "Service health" "Insufficient permissions (needs ServiceHealth.Read.All)" $false
}

# --- Summary ---
Section "Summary of Findings"
if ($issues.Count -eq 0) {
    Write-Host " No issues flagged. Tenant looks healthy." -ForegroundColor Green
    $report.Add(" No issues flagged.")
} else {
    Write-Host " $($issues.Count) item(s) flagged for review:" -ForegroundColor Yellow
    $issues | ForEach-Object {
        $line = " [!] $($_.Finding): $($_.Value)"
        Write-Host $line -ForegroundColor Yellow
        $report.Add($line)
    }
}

# Export
$tenant = ($org.VerifiedDomains | Where-Object { $_.IsDefault }).Name
$path = "$env:USERPROFILE\Desktop\TenantReport_${tenant}_$(Get-Date -Format 'yyyyMMdd').txt"
$report | Out-File -FilePath $path -Encoding UTF8
Write-Host "`nFull report saved to: $path" -ForegroundColor Cyan