From 8030e5ebb6f0836293cdb3b995f032d47124b70b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 6 Apr 2026 19:18:52 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(reporting):=20=E2=9C=A8=20improve=20gu?= =?UTF-8?q?est=20and=20user=20sign-in=20review=20reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add successful sign-in data and review cues to user and guest reporting - add dedicated cloud-only member and external account review slices - expand guest/external sorting views for stale, privileged, licensed, and no-successful-sign-in accounts --- Private/Configuration.Guests.ps1 | 157 ++++++++++++++++++- Private/Configuration.Users.ps1 | 140 ++++++++++++++++- Private/New-MyUserAuthenticationObject.ps1 | 168 +++++++++++---------- Public/Get-MyGuest.ps1 | 24 +++ Public/Get-MyUser.ps1 | 143 ++++++++++++++++++ 5 files changed, 541 insertions(+), 91 deletions(-) diff --git a/Private/Configuration.Guests.ps1 b/Private/Configuration.Guests.ps1 index c42628f..0454b3e 100644 --- a/Private/Configuration.Guests.ps1 +++ b/Private/Configuration.Guests.ps1 @@ -15,13 +15,22 @@ $Script:Guests = [ordered] @{ $pendingGuests = 0 $acceptedGuests = 0 $neverSignedInGuests = 0 + $neverSuccessfulSignInGuests = 0 + $staleGuests = 0 + $recentGuests = 0 $guestWithRoles = 0 $guestWithLicenses = 0 $guestDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $domainCounts = @{} $stateCounts = @{} + $creationTypeCounts = @{} $pendingAccounts = [System.Collections.Generic.List[object]]::new() $acceptedAccounts = [System.Collections.Generic.List[object]]::new() + $neverSuccessfulAccounts = [System.Collections.Generic.List[object]]::new() + $staleAccounts = [System.Collections.Generic.List[object]]::new() + $recentAccounts = [System.Collections.Generic.List[object]]::new() + $privilegedAccounts = [System.Collections.Generic.List[object]]::new() + $licensedAccounts = [System.Collections.Generic.List[object]]::new() $reviewCandidates = [System.Collections.Generic.List[object]]::new() foreach ($guest in $guestData) { @@ -42,11 +51,17 @@ $Script:Guests = [ordered] @{ if ($guest.NeverSignedIn) { $neverSignedInGuests++ } + if ($guest.NeverSuccessfullySignedIn) { + $neverSuccessfulSignInGuests++ + $neverSuccessfulAccounts.Add($guest) + } if ($guest.HasRoles) { $guestWithRoles++ + $privilegedAccounts.Add($guest) } if ($guest.HasLicenses) { $guestWithLicenses++ + $licensedAccounts.Add($guest) } if ($guest.GuestDomain) { [void] $guestDomains.Add($guest.GuestDomain) @@ -62,6 +77,22 @@ $Script:Guests = [ordered] @{ } $stateCounts[$guestState]++ + $creationType = if ($guest.CreationType) { $guest.CreationType } else { 'Unknown' } + if (-not $creationTypeCounts.ContainsKey($creationType)) { + $creationTypeCounts[$creationType] = 0 + } + $creationTypeCounts[$creationType]++ + + if ($null -ne $guest.LastSuccessfulSignInDaysAgo -and $guest.LastSuccessfulSignInDaysAgo -gt 180) { + $staleGuests++ + $staleAccounts.Add($guest) + } + + if ($null -ne $guest.LastSuccessfulSignInDaysAgo -and $guest.LastSuccessfulSignInDaysAgo -le 30) { + $recentGuests++ + $recentAccounts.Add($guest) + } + $reviewFlags = [System.Collections.Generic.List[string]]::new() if (-not $guest.Enabled) { $reviewFlags.Add('Disabled') @@ -72,9 +103,15 @@ $Script:Guests = [ordered] @{ if ($guest.NeverSignedIn) { $reviewFlags.Add('Never signed in') } + if ($guest.NeverSuccessfullySignedIn) { + $reviewFlags.Add('No successful sign-in') + } if (($null -ne $guest.LastSignInDaysAgo -and $guest.LastSignInDaysAgo -gt 180) -or ($null -ne $guest.LastNonInteractiveSignInDaysAgo -and $guest.LastNonInteractiveSignInDaysAgo -gt 180)) { $reviewFlags.Add('Stale sign-in') } + if ($null -ne $guest.LastSuccessfulSignInDaysAgo -and $guest.LastSuccessfulSignInDaysAgo -gt 180) { + $reviewFlags.Add('No successful sign-in 180+ days') + } if ($guest.HasRoles) { $reviewFlags.Add('Privileged') } @@ -96,6 +133,9 @@ $Script:Guests = [ordered] @{ PendingAcceptance = $pendingGuests AcceptedGuests = $acceptedGuests NeverSignedIn = $neverSignedInGuests + NeverSuccessfulSignIn = $neverSuccessfulSignInGuests + StaleSuccessful180Days = $staleGuests + ActiveSuccessful30Days = $recentGuests GuestsWithRoles = $guestWithRoles GuestsWithLicenses = $guestWithLicenses DistinctDomains = $guestDomains.Count @@ -121,6 +161,7 @@ $Script:Guests = [ordered] @{ ) | Sort-Object Count -Descending $signInDistribution = @( + [PSCustomObject]@{ Name = 'No activity data'; Count = @($guestData.Where({ $null -eq $_.NeverSignedIn -and $null -eq $_.LastSignInDaysAgo -and $null -eq $_.LastNonInteractiveSignInDaysAgo })).Count } [PSCustomObject]@{ Name = 'Never signed in'; Count = $neverSignedInGuests } [PSCustomObject]@{ Name = '0-30 days'; Count = @($guestData.Where({ $null -ne $_.LastSignInDaysAgo -and $_.LastSignInDaysAgo -le 30 })).Count } [PSCustomObject]@{ Name = '31-90 days'; Count = @($guestData.Where({ $null -ne $_.LastSignInDaysAgo -and $_.LastSignInDaysAgo -gt 30 -and $_.LastSignInDaysAgo -le 90 })).Count } @@ -128,15 +169,35 @@ $Script:Guests = [ordered] @{ [PSCustomObject]@{ Name = '180+ days'; Count = @($guestData.Where({ $null -ne $_.LastSignInDaysAgo -and $_.LastSignInDaysAgo -gt 180 })).Count } ) + $successfulSignInDistribution = @( + [PSCustomObject]@{ Name = 'No activity data'; Count = @($guestData.Where({ $null -eq $_.NeverSuccessfullySignedIn -and $null -eq $_.LastSuccessfulSignInDaysAgo })).Count } + [PSCustomObject]@{ Name = 'No successful sign-in'; Count = $neverSuccessfulSignInGuests } + [PSCustomObject]@{ Name = '0-30 days'; Count = @($guestData.Where({ $null -ne $_.LastSuccessfulSignInDaysAgo -and $_.LastSuccessfulSignInDaysAgo -le 30 })).Count } + [PSCustomObject]@{ Name = '31-90 days'; Count = @($guestData.Where({ $null -ne $_.LastSuccessfulSignInDaysAgo -and $_.LastSuccessfulSignInDaysAgo -gt 30 -and $_.LastSuccessfulSignInDaysAgo -le 90 })).Count } + [PSCustomObject]@{ Name = '91-180 days'; Count = @($guestData.Where({ $null -ne $_.LastSuccessfulSignInDaysAgo -and $_.LastSuccessfulSignInDaysAgo -gt 90 -and $_.LastSuccessfulSignInDaysAgo -le 180 })).Count } + [PSCustomObject]@{ Name = '180+ days'; Count = @($guestData.Where({ $null -ne $_.LastSuccessfulSignInDaysAgo -and $_.LastSuccessfulSignInDaysAgo -gt 180 })).Count } + ) + $accessDistribution = @( [PSCustomObject]@{ Name = 'Enabled'; Count = $enabledGuests } [PSCustomObject]@{ Name = 'Disabled'; Count = $disabledGuests } [PSCustomObject]@{ Name = 'Pending acceptance'; Count = $pendingGuests } [PSCustomObject]@{ Name = 'Never signed in'; Count = $neverSignedInGuests } + [PSCustomObject]@{ Name = 'No successful sign-in'; Count = $neverSuccessfulSignInGuests } + [PSCustomObject]@{ Name = 'Successful sign-in 180+ days'; Count = $staleGuests } [PSCustomObject]@{ Name = 'With roles'; Count = $guestWithRoles } [PSCustomObject]@{ Name = 'With licenses'; Count = $guestWithLicenses } ) + $creationTypeDistribution = @( + foreach ($key in $creationTypeCounts.Keys) { + [PSCustomObject]@{ + Name = $key + Count = $creationTypeCounts[$key] + } + } + ) | Sort-Object Count -Descending + $authenticationSummary = @() try { $authPolicy = Get-MyAuthenticationMethodsPolicy @@ -226,9 +287,16 @@ $Script:Guests = [ordered] @{ DomainDistribution = $domainDistribution StateDistribution = $stateDistribution SignInDistribution = $signInDistribution + SuccessfulSignInDistribution = $successfulSignInDistribution AccessDistribution = $accessDistribution + CreationTypeDistribution = $creationTypeDistribution PendingAccounts = @($pendingAccounts) AcceptedAccounts = @($acceptedAccounts) + NeverSuccessfulAccounts = @($neverSuccessfulAccounts) + StaleAccounts = @($staleAccounts) + RecentAccounts = @($recentAccounts) + PrivilegedAccounts = @($privilegedAccounts) + LicensedAccounts = @($licensedAccounts) ReviewCandidates = @($reviewCandidates) AuthenticationPolicy = $authenticationSummary TermsOfUse = $termsOfUseSummary @@ -253,12 +321,13 @@ $Script:Guests = [ordered] @{ New-HTMLInfoCard -Title 'Total External Accounts' -Number $overview.TotalGuests -Subtitle 'All guest and external accounts in scope' -Icon '👥' -IconColor '#0078d4' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px New-HTMLInfoCard -Title 'Enabled / Disabled' -Number "$($overview.EnabledGuests) / $($overview.DisabledGuests)" -Subtitle 'Account state split' -Icon '✅' -IconColor '#198754' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px New-HTMLInfoCard -Title 'Pending / Accepted' -Number "$($overview.PendingAcceptance) / $($overview.AcceptedGuests)" -Subtitle 'Invitation lifecycle split' -Icon '📨' -IconColor '#fd7e14' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px - New-HTMLInfoCard -Title 'Never Signed In' -Number $overview.NeverSignedIn -Subtitle 'External accounts without observed sign-in activity' -Icon '⏱️' -IconColor '#dc3545' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px + New-HTMLInfoCard -Title 'No Successful Sign-in' -Number $overview.NeverSuccessfulSignIn -Subtitle 'External accounts without recorded successful sign-in activity' -Icon '⏱️' -IconColor '#dc3545' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px } New-HTMLSection -Invisible { New-HTMLInfoCard -Title 'Accounts With Roles' -Number $overview.GuestsWithRoles -Subtitle 'Privileged external identities' -Icon '🛡️' -IconColor '#6f42c1' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px New-HTMLInfoCard -Title 'Accounts With Licenses' -Number $overview.GuestsWithLicenses -Subtitle 'Licensed external identities' -Icon '🎫' -IconColor '#20c997' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px - New-HTMLInfoCard -Title 'Distinct Domains' -Number $overview.DistinctDomains -Subtitle 'Partner domains represented' -Icon '🌐' -IconColor '#ffc107' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px + New-HTMLInfoCard -Title 'Successful 180+ Days' -Number $overview.StaleSuccessful180Days -Subtitle 'No recent successful sign-in recorded' -Icon '📉' -IconColor '#ffc107' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px + New-HTMLInfoCard -Title 'Distinct Domains' -Number $overview.DistinctDomains -Subtitle 'Partner domains represented' -Icon '🌐' -IconColor '#0dcaf0' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px } New-HTMLSection -Invisible { New-HTMLPanel { @@ -269,8 +338,8 @@ $Script:Guests = [ordered] @{ } } New-HTMLPanel { - New-HTMLChart -Title 'External Sign-in Recency' { - foreach ($item in $guestSummary.SignInDistribution) { + New-HTMLChart -Title 'External Successful Sign-in Recency' { + foreach ($item in $guestSummary.SuccessfulSignInDistribution) { New-ChartPie -Name $item.Name -Value $item.Count } } @@ -284,6 +353,22 @@ $Script:Guests = [ordered] @{ } } } + New-HTMLPanel { + New-HTMLChart -Title 'Creation Type Distribution' { + foreach ($item in $guestSummary.CreationTypeDistribution) { + New-ChartBar -Name $item.Name -Value $item.Count + } + } + } + } + New-HTMLSection -HeaderText 'Sign-in Signals' -Invisible { + New-HTMLPanel { + New-HTMLChart -Title 'External Sign-in Recency' { + foreach ($item in $guestSummary.SignInDistribution) { + New-ChartPie -Name $item.Name -Value $item.Count + } + } + } New-HTMLPanel { New-HTMLChart -Title 'Top External Domains' { foreach ($item in ($guestSummary.DomainDistribution | Select-Object -First 10)) { @@ -335,6 +420,10 @@ $Script:Guests = [ordered] @{ New-HTMLTableCondition -Name 'ExternalUserState' -Operator eq -Value '' -ComparisonType string -BackgroundColor OldGold -HighlightHeaders 'ExternalUserState' New-HTMLTableCondition -Name 'NeverSignedIn' -Operator eq -Value $true -ComparisonType string -BackgroundColor PeachOrange -HighlightHeaders 'NeverSignedIn', 'LastSignInDateTime', 'LastNonInteractiveSignInDateTime' + New-HTMLTableCondition -Name 'NeverSuccessfullySignedIn' -Operator eq -Value $true -ComparisonType string -BackgroundColor CoralRed -HighlightHeaders 'NeverSuccessfullySignedIn', 'LastSuccessfulSignInDateTime' + New-HTMLTableCondition -Name 'SignInPattern' -Operator eq -Value 'Non-interactive only' -ComparisonType string -BackgroundColor LightSkyBlue -HighlightHeaders 'SignInPattern', 'LastNonInteractiveSignInDateTime' + New-HTMLTableCondition -Name 'SignInPattern' -Operator eq -Value 'Interactive only' -ComparisonType string -BackgroundColor LightGreen -HighlightHeaders 'SignInPattern', 'LastSignInDateTime' + New-HTMLTableCondition -Name 'SignInPattern' -Operator eq -Value 'Interactive + non-interactive' -ComparisonType string -BackgroundColor MediumSpringGreen -HighlightHeaders 'SignInPattern' New-HTMLTableCondition -Name 'HasRoles' -Operator eq -Value $true -ComparisonType string -BackgroundColor LightSkyBlue -HighlightHeaders 'HasRoles', 'RoleCount', 'Roles' New-HTMLTableCondition -Name 'HasLicenses' -Operator eq -Value $true -ComparisonType string -BackgroundColor LightGreen -HighlightHeaders 'HasLicenses', 'LicenseCount', 'Licenses' @@ -348,6 +437,11 @@ $Script:Guests = [ordered] @{ New-HTMLTableCondition -Name 'LastNonInteractiveSignInDaysAgo' -Value 90 -Operator le -ComparisonType number -BackgroundColor LaserLemon -HighlightHeaders 'LastNonInteractiveSignInDaysAgo', 'LastNonInteractiveSignInDateTime' New-HTMLTableCondition -Name 'LastNonInteractiveSignInDaysAgo' -Value 30 -Operator le -ComparisonType number -BackgroundColor MediumSpringGreen -HighlightHeaders 'LastNonInteractiveSignInDaysAgo', 'LastNonInteractiveSignInDateTime' + New-HTMLTableCondition -Name 'LastSuccessfulSignInDaysAgo' -Value 180 -Operator gt -ComparisonType number -BackgroundColor CoralRed -HighlightHeaders 'LastSuccessfulSignInDaysAgo', 'LastSuccessfulSignInDateTime' + New-HTMLTableCondition -Name 'LastSuccessfulSignInDaysAgo' -Value 180 -Operator le -ComparisonType number -BackgroundColor SunsetOrange -HighlightHeaders 'LastSuccessfulSignInDaysAgo', 'LastSuccessfulSignInDateTime' + New-HTMLTableCondition -Name 'LastSuccessfulSignInDaysAgo' -Value 90 -Operator le -ComparisonType number -BackgroundColor LaserLemon -HighlightHeaders 'LastSuccessfulSignInDaysAgo', 'LastSuccessfulSignInDateTime' + New-HTMLTableCondition -Name 'LastSuccessfulSignInDaysAgo' -Value 30 -Operator le -ComparisonType number -BackgroundColor MediumSpringGreen -HighlightHeaders 'LastSuccessfulSignInDaysAgo', 'LastSuccessfulSignInDateTime' + New-HTMLTableCondition -Name 'LicensesStatus' -Operator contains -Value 'Direct' -ComparisonType string -BackgroundColor LightSkyBlue New-HTMLTableCondition -Name 'LicensesStatus' -Operator contains -Value 'Group' -ComparisonType string -BackgroundColor LightGreen New-HTMLTableCondition -Name 'LicensesStatus' -Operator contains -Value 'Duplicate' -ComparisonType string -BackgroundColor PeachOrange @@ -386,6 +480,61 @@ $Script:Guests = [ordered] @{ } } } + New-HTMLTab -Name "No Successful Sign-in ($(@($guestSummary.NeverSuccessfulAccounts).Count))" { + if (@($guestSummary.NeverSuccessfulAccounts).Count -gt 0) { + New-HTMLSection -HeaderText 'External Accounts Without Successful Sign-in' { + New-HTMLTable -DataTable $guestSummary.NeverSuccessfulAccounts -Filtering $guestTableConditions -ScrollX + } + } else { + New-HTMLSection -HeaderText 'External Accounts Without Successful Sign-in' { + New-HTMLText -Text 'All guest or external accounts have a recorded successful sign-in.' -Color Orange + } + } + } + New-HTMLTab -Name "Recent Successful ($(@($guestSummary.RecentAccounts).Count))" { + if (@($guestSummary.RecentAccounts).Count -gt 0) { + New-HTMLSection -HeaderText 'Recently Active External Accounts' { + New-HTMLTable -DataTable $guestSummary.RecentAccounts -Filtering $guestTableConditions -ScrollX + } + } else { + New-HTMLSection -HeaderText 'Recently Active External Accounts' { + New-HTMLText -Text 'No external accounts have a successful sign-in within the last 30 days.' -Color Orange + } + } + } + New-HTMLTab -Name "Stale Successful ($(@($guestSummary.StaleAccounts).Count))" { + if (@($guestSummary.StaleAccounts).Count -gt 0) { + New-HTMLSection -HeaderText 'Stale External Accounts' { + New-HTMLTable -DataTable $guestSummary.StaleAccounts -Filtering $guestTableConditions -ScrollX + } + } else { + New-HTMLSection -HeaderText 'Stale External Accounts' { + New-HTMLText -Text 'No external accounts are currently stale based on successful sign-in activity.' -Color Orange + } + } + } + New-HTMLTab -Name "Privileged ($(@($guestSummary.PrivilegedAccounts).Count))" { + if (@($guestSummary.PrivilegedAccounts).Count -gt 0) { + New-HTMLSection -HeaderText 'Privileged External Accounts' { + New-HTMLTable -DataTable $guestSummary.PrivilegedAccounts -Filtering $guestTableConditions -ScrollX + } + } else { + New-HTMLSection -HeaderText 'Privileged External Accounts' { + New-HTMLText -Text 'No guest or external accounts currently hold directory roles.' -Color Orange + } + } + } + New-HTMLTab -Name "Licensed ($(@($guestSummary.LicensedAccounts).Count))" { + if (@($guestSummary.LicensedAccounts).Count -gt 0) { + New-HTMLSection -HeaderText 'Licensed External Accounts' { + New-HTMLTable -DataTable $guestSummary.LicensedAccounts -Filtering $guestTableConditions -ScrollX + } + } else { + New-HTMLSection -HeaderText 'Licensed External Accounts' { + New-HTMLText -Text 'No guest or external accounts currently have licenses assigned.' -Color Orange + } + } + } New-HTMLTab -Name "Review Queue ($(@($guestSummary.ReviewCandidates).Count))" { if (@($guestSummary.ReviewCandidates).Count -gt 0) { New-HTMLSection -HeaderText 'External Accounts Requiring Review' { diff --git a/Private/Configuration.Users.ps1 b/Private/Configuration.Users.ps1 index 7e5ff35..9ea96fc 100644 --- a/Private/Configuration.Users.ps1 +++ b/Private/Configuration.Users.ps1 @@ -20,12 +20,18 @@ $Script:Users = [ordered] @{ $usersWithoutLicenses = 0 $usersWithoutManager = 0 $neverSignedInUsers = 0 + $neverSuccessfulSignInUsers = 0 $inactiveUsers = 0 $recentUsers = 0 + $inactiveSuccessfulUsers = 0 + $recentSuccessfulUsers = 0 $domainCounts = @{} $userTypeCounts = @{} + $cloudOnlyProfileCounts = @{} $memberAccounts = [System.Collections.Generic.List[object]]::new() $guestAccounts = [System.Collections.Generic.List[object]]::new() + $cloudOnlyMemberAccounts = [System.Collections.Generic.List[object]]::new() + $cloudOnlyReviewQueue = [System.Collections.Generic.List[object]]::new() $reviewCandidates = [System.Collections.Generic.List[object]]::new() foreach ($user in $userData) { @@ -69,6 +75,10 @@ $Script:Users = [ordered] @{ $neverSignedInUsers++ } + if ($user.NeverSuccessfullySignedIn -eq $true) { + $neverSuccessfulSignInUsers++ + } + if (($null -ne $user.LastSignInDaysAgo -and $user.LastSignInDaysAgo -gt 90) -or ($null -ne $user.LastNonInteractiveSignInDaysAgo -and $user.LastNonInteractiveSignInDaysAgo -gt 90)) { $inactiveUsers++ } @@ -77,6 +87,14 @@ $Script:Users = [ordered] @{ $recentUsers++ } + if ($null -ne $user.LastSuccessfulSignInDaysAgo -and $user.LastSuccessfulSignInDaysAgo -gt 90) { + $inactiveSuccessfulUsers++ + } + + if ($null -ne $user.LastSuccessfulSignInDaysAgo -and $user.LastSuccessfulSignInDaysAgo -le 30) { + $recentSuccessfulUsers++ + } + if ($user.UserDomain) { if (-not $domainCounts.ContainsKey($user.UserDomain)) { $domainCounts[$user.UserDomain] = 0 @@ -84,6 +102,20 @@ $Script:Users = [ordered] @{ $domainCounts[$user.UserDomain]++ } + if ($user.IsCloudOnlyMemberCandidate) { + $cloudOnlyMemberAccounts.Add($user) + + $cloudOnlyProfile = if ($user.CloudOnlyProfile) { $user.CloudOnlyProfile } else { 'Not classified' } + if (-not $cloudOnlyProfileCounts.ContainsKey($cloudOnlyProfile)) { + $cloudOnlyProfileCounts[$cloudOnlyProfile] = 0 + } + $cloudOnlyProfileCounts[$cloudOnlyProfile]++ + + if ($user.CloudOnlyReviewPriority -in @('High', 'Medium')) { + $cloudOnlyReviewQueue.Add($user) + } + } + $reviewFlags = [System.Collections.Generic.List[string]]::new() if (-not $user.Enabled) { $reviewFlags.Add('Disabled') @@ -97,9 +129,15 @@ $Script:Users = [ordered] @{ if ($user.NeverSignedIn -eq $true) { $reviewFlags.Add('Never signed in') } + if ($user.NeverSuccessfullySignedIn -eq $true) { + $reviewFlags.Add('No successful sign-in') + } if (($null -ne $user.LastSignInDaysAgo -and $user.LastSignInDaysAgo -gt 90) -or ($null -ne $user.LastNonInteractiveSignInDaysAgo -and $user.LastNonInteractiveSignInDaysAgo -gt 90)) { $reviewFlags.Add('Inactive 90+ days') } + if ($null -ne $user.LastSuccessfulSignInDaysAgo -and $user.LastSuccessfulSignInDaysAgo -gt 90) { + $reviewFlags.Add('No successful sign-in 90+ days') + } if ($null -ne $user.LastPasswordChangeDays -and $user.LastPasswordChangeDays -gt 180) { $reviewFlags.Add('Password older than 180 days') } @@ -109,6 +147,12 @@ $Script:Users = [ordered] @{ if ($user.LicensesStatus -contains 'Error') { $reviewFlags.Add('License issue') } + if ($user.IsCloudOnlyMemberCandidate -and $user.CloudOnlyProfile -eq 'Likely human account') { + $reviewFlags.Add('Cloud-only member with human usage pattern') + } + if ($user.IsCloudOnlyMemberCandidate -and $user.CloudOnlyProfile -eq 'Needs review') { + $reviewFlags.Add('Cloud-only member requires classification review') + } if ($reviewFlags.Count -gt 0) { $reviewFlagsText = $reviewFlags.ToArray() -join ', ' @@ -129,8 +173,15 @@ $Script:Users = [ordered] @{ UnlicensedUsers = $usersWithoutLicenses UsersWithoutManager = $usersWithoutManager NeverSignedIn = $neverSignedInUsers + NeverSuccessfulSignIn = $neverSuccessfulSignInUsers Inactive90Days = $inactiveUsers Active30Days = $recentUsers + InactiveSuccessful90Days = $inactiveSuccessfulUsers + ActiveSuccessful30Days = $recentSuccessfulUsers + CloudOnlyMemberCandidates = $cloudOnlyMemberAccounts.Count + CloudOnlyLikelyHuman = @($cloudOnlyMemberAccounts.Where({ $_.CloudOnlyProfile -eq 'Likely human account' })).Count + CloudOnlyLikelyWorkload = @($cloudOnlyMemberAccounts.Where({ $_.CloudOnlyProfile -eq 'Likely workload/resource' })).Count + CloudOnlyNeedsReview = @($cloudOnlyMemberAccounts.Where({ $_.CloudOnlyProfile -eq 'Needs review' })).Count } ) @@ -161,6 +212,15 @@ $Script:Users = [ordered] @{ [PSCustomObject]@{ Name = '180+ days'; Count = @($userData.Where({ ($null -ne $_.LastSignInDaysAgo -and $_.LastSignInDaysAgo -gt 180) -or ($null -ne $_.LastNonInteractiveSignInDaysAgo -and $_.LastNonInteractiveSignInDaysAgo -gt 180) })).Count } ) + $successfulSignInDistribution = @( + [PSCustomObject]@{ Name = 'No activity data'; Count = @($userData.Where({ $null -eq $_.NeverSuccessfullySignedIn -and $null -eq $_.LastSuccessfulSignInDaysAgo })).Count } + [PSCustomObject]@{ Name = 'No successful sign-in'; Count = @($userData.Where({ $_.NeverSuccessfullySignedIn -eq $true })).Count } + [PSCustomObject]@{ Name = '0-30 days'; Count = @($userData.Where({ $null -ne $_.LastSuccessfulSignInDaysAgo -and $_.LastSuccessfulSignInDaysAgo -le 30 })).Count } + [PSCustomObject]@{ Name = '31-90 days'; Count = @($userData.Where({ $null -ne $_.LastSuccessfulSignInDaysAgo -and $_.LastSuccessfulSignInDaysAgo -gt 30 -and $_.LastSuccessfulSignInDaysAgo -le 90 })).Count } + [PSCustomObject]@{ Name = '91-180 days'; Count = @($userData.Where({ $null -ne $_.LastSuccessfulSignInDaysAgo -and $_.LastSuccessfulSignInDaysAgo -gt 90 -and $_.LastSuccessfulSignInDaysAgo -le 180 })).Count } + [PSCustomObject]@{ Name = '180+ days'; Count = @($userData.Where({ $null -ne $_.LastSuccessfulSignInDaysAgo -and $_.LastSuccessfulSignInDaysAgo -gt 180 })).Count } + ) + $identitySourceDistribution = @( [PSCustomObject]@{ Name = 'Synchronized'; Count = $synchronizedUsers } [PSCustomObject]@{ Name = 'Cloud only'; Count = $cloudOnlyUsers } @@ -173,15 +233,28 @@ $Script:Users = [ordered] @{ [PSCustomObject]@{ Name = 'License issues'; Count = @($userData.Where({ $_.LicensesStatus -contains 'Error' })).Count } ) + $cloudOnlyProfileDistribution = @( + foreach ($key in $cloudOnlyProfileCounts.Keys) { + [PSCustomObject]@{ + Name = $key + Count = $cloudOnlyProfileCounts[$key] + } + } + ) | Sort-Object Count -Descending + [PSCustomObject]@{ Overview = $overview DomainDistribution = $domainDistribution UserTypeDistribution = $userTypeDistribution SignInDistribution = $signInDistribution + SuccessfulSignInDistribution = $successfulSignInDistribution IdentitySourceDistribution = $identitySourceDistribution LicenseDistribution = $licenseDistribution + CloudOnlyProfileDistribution = $cloudOnlyProfileDistribution MemberAccounts = @($memberAccounts) GuestAccounts = @($guestAccounts) + CloudOnlyMemberAccounts = @($cloudOnlyMemberAccounts) + CloudOnlyReviewQueue = @($cloudOnlyReviewQueue) ReviewCandidates = @($reviewCandidates) } } @@ -215,6 +288,10 @@ $Script:Users = [ordered] @{ New-HTMLTableCondition -Name 'LicensesStatus' -Operator eq -Value '' -ComparisonType string -BackgroundColor OldGold New-HTMLTableCondition -Name 'NeverSignedIn' -Operator eq -Value $true -ComparisonType string -BackgroundColor PeachOrange -HighlightHeaders 'NeverSignedIn', 'LastSignInDateTime', 'LastNonInteractiveSignInDateTime' + New-HTMLTableCondition -Name 'NeverSuccessfullySignedIn' -Operator eq -Value $true -ComparisonType string -BackgroundColor CoralRed -HighlightHeaders 'NeverSuccessfullySignedIn', 'LastSuccessfulSignInDateTime' + New-HTMLTableCondition -Name 'SignInPattern' -Operator eq -Value 'Non-interactive only' -ComparisonType string -BackgroundColor LightSkyBlue -HighlightHeaders 'SignInPattern', 'LastNonInteractiveSignInDateTime' + New-HTMLTableCondition -Name 'SignInPattern' -Operator eq -Value 'Interactive only' -ComparisonType string -BackgroundColor LightGreen -HighlightHeaders 'SignInPattern', 'LastSignInDateTime' + New-HTMLTableCondition -Name 'SignInPattern' -Operator eq -Value 'Interactive + non-interactive' -ComparisonType string -BackgroundColor MediumSpringGreen -HighlightHeaders 'SignInPattern' New-HTMLTableCondition -Name 'LastSignInDaysAgo' -Value 180 -Operator gt -ComparisonType number -BackgroundColor CoralRed -HighlightHeaders 'LastSignInDaysAgo', 'LastSignInDateTime' New-HTMLTableCondition -Name 'LastSignInDaysAgo' -Value 180 -Operator le -ComparisonType number -BackgroundColor SunsetOrange -HighlightHeaders 'LastSignInDaysAgo', 'LastSignInDateTime' @@ -226,6 +303,11 @@ $Script:Users = [ordered] @{ New-HTMLTableCondition -Name 'LastNonInteractiveSignInDaysAgo' -Value 90 -Operator le -ComparisonType number -BackgroundColor LaserLemon -HighlightHeaders 'LastNonInteractiveSignInDaysAgo', 'LastNonInteractiveSignInDateTime' New-HTMLTableCondition -Name 'LastNonInteractiveSignInDaysAgo' -Value 30 -Operator le -ComparisonType number -BackgroundColor MediumSpringGreen -HighlightHeaders 'LastNonInteractiveSignInDaysAgo', 'LastNonInteractiveSignInDateTime' + New-HTMLTableCondition -Name 'LastSuccessfulSignInDaysAgo' -Value 180 -Operator gt -ComparisonType number -BackgroundColor CoralRed -HighlightHeaders 'LastSuccessfulSignInDaysAgo', 'LastSuccessfulSignInDateTime' + New-HTMLTableCondition -Name 'LastSuccessfulSignInDaysAgo' -Value 180 -Operator le -ComparisonType number -BackgroundColor SunsetOrange -HighlightHeaders 'LastSuccessfulSignInDaysAgo', 'LastSuccessfulSignInDateTime' + New-HTMLTableCondition -Name 'LastSuccessfulSignInDaysAgo' -Value 90 -Operator le -ComparisonType number -BackgroundColor LaserLemon -HighlightHeaders 'LastSuccessfulSignInDaysAgo', 'LastSuccessfulSignInDateTime' + New-HTMLTableCondition -Name 'LastSuccessfulSignInDaysAgo' -Value 30 -Operator le -ComparisonType number -BackgroundColor MediumSpringGreen -HighlightHeaders 'LastSuccessfulSignInDaysAgo', 'LastSuccessfulSignInDateTime' + New-HTMLTableCondition -Name 'LastPasswordChangeDays' -Value 180 -Operator gt -ComparisonType number -BackgroundColor CoralRed -HighlightHeaders 'LastPasswordChangeDays', 'LastPasswordChangeDateTime' New-HTMLTableCondition -Name 'LastPasswordChangeDays' -Value 180 -Operator le -ComparisonType number -BackgroundColor SunsetOrange -HighlightHeaders 'LastPasswordChangeDays', 'LastPasswordChangeDateTime' New-HTMLTableCondition -Name 'LastPasswordChangeDays' -Value 90 -Operator le -ComparisonType number -BackgroundColor LaserLemon -HighlightHeaders 'LastPasswordChangeDays', 'LastPasswordChangeDateTime' @@ -235,6 +317,12 @@ $Script:Users = [ordered] @{ New-HTMLTableCondition -Name 'LastSynchronizedDays' -Value 180 -Operator le -ComparisonType number -BackgroundColor SunsetOrange -HighlightHeaders 'LastSynchronizedDays', 'LastSynchronized' New-HTMLTableCondition -Name 'LastSynchronizedDays' -Value 90 -Operator le -ComparisonType number -BackgroundColor LaserLemon -HighlightHeaders 'LastSynchronizedDays', 'LastSynchronized' New-HTMLTableCondition -Name 'LastSynchronizedDays' -Value 30 -Operator le -ComparisonType number -BackgroundColor MediumSpringGreen -HighlightHeaders 'LastSynchronizedDays', 'LastSynchronized' + + New-HTMLTableCondition -Name 'CloudOnlyProfile' -Operator eq -Value 'Likely human account' -ComparisonType string -BackgroundColor Salmon -HighlightHeaders 'CloudOnlyProfile', 'CloudOnlySignals' + New-HTMLTableCondition -Name 'CloudOnlyProfile' -Operator eq -Value 'Needs review' -ComparisonType string -BackgroundColor OldGold -HighlightHeaders 'CloudOnlyProfile', 'CloudOnlySignals' + New-HTMLTableCondition -Name 'CloudOnlyProfile' -Operator eq -Value 'Likely workload/resource' -ComparisonType string -BackgroundColor LightSkyBlue -HighlightHeaders 'CloudOnlyProfile', 'CloudOnlySignals' + New-HTMLTableCondition -Name 'CloudOnlyReviewPriority' -Operator eq -Value 'High' -ComparisonType string -BackgroundColor CoralRed -HighlightHeaders 'CloudOnlyReviewPriority', 'CloudOnlyProfile' + New-HTMLTableCondition -Name 'CloudOnlyReviewPriority' -Operator eq -Value 'Medium' -ComparisonType string -BackgroundColor SunsetOrange -HighlightHeaders 'CloudOnlyReviewPriority', 'CloudOnlyProfile' } New-HTMLTabPanel { @@ -251,8 +339,14 @@ $Script:Users = [ordered] @{ New-HTMLSection -Invisible { New-HTMLInfoCard -Title 'Synchronized / Cloud' -Number "$($overview.SynchronizedUsers) / $($overview.CloudOnlyUsers)" -Subtitle 'Identity source split' -Icon '🔄' -IconColor '#fd7e14' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px New-HTMLInfoCard -Title 'Without Manager' -Number $overview.UsersWithoutManager -Subtitle 'Users missing manager assignment' -Icon '🧭' -IconColor '#dc3545' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px - New-HTMLInfoCard -Title 'Never Signed In' -Number $overview.NeverSignedIn -Subtitle 'Only when sign-in activity is available' -Icon '⏱️' -IconColor '#dc3545' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px - New-HTMLInfoCard -Title 'Inactive 90+ Days' -Number $overview.Inactive90Days -Subtitle 'No recent sign-in activity reported' -Icon '📉' -IconColor '#ffc107' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px + New-HTMLInfoCard -Title 'No Successful Sign-in' -Number $overview.NeverSuccessfulSignIn -Subtitle 'Interactive or non-interactive success not recorded' -Icon '⏱️' -IconColor '#dc3545' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px + New-HTMLInfoCard -Title 'Successful Inactive 90+ Days' -Number $overview.InactiveSuccessful90Days -Subtitle 'No recent successful sign-in recorded' -Icon '📉' -IconColor '#ffc107' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px + } + New-HTMLSection -Invisible { + New-HTMLInfoCard -Title 'Cloud-only Members' -Number $overview.CloudOnlyMemberCandidates -Subtitle 'Enabled member accounts that are not synchronized' -Icon '☁️' -IconColor '#0d6efd' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px + New-HTMLInfoCard -Title 'Likely Human / Workload' -Number "$($overview.CloudOnlyLikelyHuman) / $($overview.CloudOnlyLikelyWorkload)" -Subtitle 'Heuristic split for cloud-only members' -Icon '🧩' -IconColor '#6f42c1' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px + New-HTMLInfoCard -Title 'Needs Review' -Number $overview.CloudOnlyNeedsReview -Subtitle 'Cloud-only members without a clear pattern' -Icon '🔍' -IconColor '#fd7e14' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px + New-HTMLInfoCard -Title 'Successful Active 30 Days' -Number $overview.ActiveSuccessful30Days -Subtitle 'Users with recent successful sign-in activity' -Icon '📈' -IconColor '#198754' -Style 'Standard' -ShadowIntensity 'Normal' -BorderRadius 2px } New-HTMLSection -Invisible { New-HTMLPanel { @@ -263,8 +357,8 @@ $Script:Users = [ordered] @{ } } New-HTMLPanel { - New-HTMLChart -Title 'User Sign-in Recency' { - foreach ($item in $userSummary.SignInDistribution) { + New-HTMLChart -Title 'Successful Sign-in Recency' { + foreach ($item in $userSummary.SuccessfulSignInDistribution) { New-ChartPie -Name $item.Name -Value $item.Count } } @@ -278,6 +372,15 @@ $Script:Users = [ordered] @{ } } } + New-HTMLPanel { + New-HTMLChart -Title 'Cloud-only Member Profiles' { + foreach ($item in $userSummary.CloudOnlyProfileDistribution) { + New-ChartPie -Name $item.Name -Value $item.Count + } + } + } + } + New-HTMLSection -HeaderText 'Identity Signals' -Invisible { New-HTMLPanel { New-HTMLChart -Title 'License Coverage' { foreach ($item in $userSummary.LicenseDistribution) { @@ -285,6 +388,13 @@ $Script:Users = [ordered] @{ } } } + New-HTMLPanel { + New-HTMLChart -Title 'User Sign-in Recency' { + foreach ($item in $userSummary.SignInDistribution) { + New-ChartPie -Name $item.Name -Value $item.Count + } + } + } } New-HTMLSection -HeaderText 'User Domains' -Invisible { New-HTMLPanel { @@ -331,6 +441,28 @@ $Script:Users = [ordered] @{ } } } + New-HTMLTab -Name "Cloud-only Members ($(@($userSummary.CloudOnlyMemberAccounts).Count))" { + if (@($userSummary.CloudOnlyMemberAccounts).Count -gt 0) { + New-HTMLSection -HeaderText 'Enabled Cloud-only Member Accounts' { + New-HTMLTable -DataTable $userSummary.CloudOnlyMemberAccounts -Filtering $userTableConditions -ScrollX + } + } else { + New-HTMLSection -HeaderText 'Enabled Cloud-only Member Accounts' { + New-HTMLText -Text 'No enabled cloud-only member accounts were detected in the current dataset.' -Color Orange + } + } + } + New-HTMLTab -Name "Cloud-only Review ($(@($userSummary.CloudOnlyReviewQueue).Count))" { + if (@($userSummary.CloudOnlyReviewQueue).Count -gt 0) { + New-HTMLSection -HeaderText 'Cloud-only Member Review Queue' { + New-HTMLTable -DataTable $userSummary.CloudOnlyReviewQueue -Filtering $userTableConditions -ScrollX + } + } else { + New-HTMLSection -HeaderText 'Cloud-only Member Review Queue' { + New-HTMLText -Text 'No cloud-only member accounts currently require classification review.' -Color Orange + } + } + } New-HTMLTab -Name "Review Queue ($(@($userSummary.ReviewCandidates).Count))" { if (@($userSummary.ReviewCandidates).Count -gt 0) { New-HTMLSection -HeaderText 'Users Requiring Review' { diff --git a/Private/New-MyUserAuthenticationObject.ps1 b/Private/New-MyUserAuthenticationObject.ps1 index 6eaa771..0ad76ff 100644 --- a/Private/New-MyUserAuthenticationObject.ps1 +++ b/Private/New-MyUserAuthenticationObject.ps1 @@ -1,89 +1,91 @@ -function New-MyUserAuthenticationObject { - param( - [Parameter(Mandatory)] - [PSCustomObject]$User, - - [Parameter(Mandatory)] - [array]$AuthMethods, # Summary methods for this user - - [Parameter(Mandatory)] - [array]$MethodTypes, # Derived from AuthMethods (@odata.type) - - [Parameter(Mandatory)] - [hashtable]$Details, # Populated details hashtable for this user - - [Parameter(Mandatory)] - [datetime]$Today - ) - - # Calculate Default MFA Method (Example logic, might need refinement based on policy) - $defaultMfaMethod = 'none' - if ($Details.MicrosoftAuthenticator.Count -gt 0) { - $defaultMfaMethod = 'Microsoft Authenticator' - } elseif ($Details.Fido2Keys.Count -gt 0) { - $defaultMfaMethod = 'FIDO2 Security Key' - } elseif ($Details.PhoneMethods.Count -gt 0) { - $defaultMfaMethod = 'Phone' - } elseif ($Details.WindowsHelloMethods.Count -gt 0) { - $defaultMfaMethod = 'Windows Hello' - } elseif ($Details.SoftwareOath.Count -gt 0) { - $defaultMfaMethod = 'Software Oath' - } - - # Build the final object - $resultObject = [PSCustomObject]@{ - UserId = $User.Id - UserPrincipalName = $User.UserPrincipalName - DisplayName = $User.DisplayName - Enabled = $User.AccountEnabled - IsCloudOnly = -not $User.OnPremisesSyncEnabled - +function New-MyUserAuthenticationObject { + param( + [Parameter(Mandatory)] + [PSCustomObject]$User, + + [Parameter(Mandatory)] + [array]$AuthMethods, # Summary methods for this user + + [Parameter(Mandatory)] + [array]$MethodTypes, # Derived from AuthMethods (@odata.type) + + [Parameter(Mandatory)] + [hashtable]$Details, # Populated details hashtable for this user + + [Parameter(Mandatory)] + [datetime]$Today + ) + + # Calculate Default MFA Method (Example logic, might need refinement based on policy) + $defaultMfaMethod = 'none' + if ($Details.MicrosoftAuthenticator.Count -gt 0) { + $defaultMfaMethod = 'Microsoft Authenticator' + } elseif ($Details.Fido2Keys.Count -gt 0) { + $defaultMfaMethod = 'FIDO2 Security Key' + } elseif ($Details.PhoneMethods.Count -gt 0) { + $defaultMfaMethod = 'Phone' + } elseif ($Details.WindowsHelloMethods.Count -gt 0) { + $defaultMfaMethod = 'Windows Hello' + } elseif ($Details.SoftwareOath.Count -gt 0) { + $defaultMfaMethod = 'Software Oath' + } + + # Build the final object + $resultObject = [PSCustomObject]@{ + UserId = $User.Id + UserPrincipalName = $User.UserPrincipalName + DisplayName = $User.DisplayName + Enabled = $User.AccountEnabled + IsCloudOnly = -not $User.OnPremisesSyncEnabled + LastSignInDateTime = if ($User.SignInActivity) { $User.SignInActivity.LastSignInDateTime } else { $null } LastSignInDaysAgo = if ($User.SignInActivity -and $User.SignInActivity.LastSignInDateTime) { [math]::Round((New-TimeSpan -Start $User.SignInActivity.LastSignInDateTime -End $Today).TotalDays, 0) } else { $null } LastNonInteractiveSignInDateTime = if ($User.SignInActivity) { $User.SignInActivity.LastNonInteractiveSignInDateTime } else { $null } LastNonInteractiveSignInDaysAgo = if ($User.SignInActivity -and $User.SignInActivity.LastNonInteractiveSignInDateTime) { [math]::Round((New-TimeSpan -Start $User.SignInActivity.LastNonInteractiveSignInDateTime -End $Today).TotalDays, 0) } else { $null } + LastSuccessfulSignInDateTime = if ($User.SignInActivity) { $User.SignInActivity.LastSuccessfulSignInDateTime } else { $null } + LastSuccessfulSignInDaysAgo = if ($User.SignInActivity -and $User.SignInActivity.LastSuccessfulSignInDateTime) { [math]::Round((New-TimeSpan -Start $User.SignInActivity.LastSuccessfulSignInDateTime -End $Today).TotalDays, 0) } else { $null } # Authentication Status - DefaultMfaMethod = $defaultMfaMethod - IsMfaCapable = $MethodTypes -match '(microsoftAuthenticatorAuthenticationMethod|phoneAuthenticationMethod|fido2AuthenticationMethod|softwareOathAuthenticationMethod)' | Sort-Object -Unique - IsPasswordlessCapable = $MethodTypes -match '(fido2AuthenticationMethod|windowsHelloForBusinessAuthenticationMethod)' -and (-not $Details.PasswordMethods) - MethodTypesRegistered = $MethodTypes | Sort-Object -Unique # Renamed for clarity - - # Method Types (Boolean Flags) - 'PasswordMethodRegistered' = $Details.PasswordMethods # This is just a boolean derived earlier - 'Microsoft Auth Passwordless' = $MethodTypes -contains 'microsoftAuthenticatorAuthenticationMethod' # Check specific type - 'FIDO2 Security Key' = $MethodTypes -contains 'fido2AuthenticationMethod' - 'Device Bound PushKey' = $MethodTypes -contains 'deviceBasedPushAuthenticationMethod' # Legacy column retained - 'Device Bound Push' = ($MethodTypes -contains 'deviceBasedPushAuthenticationMethod') -or ( - ($AuthMethods | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.deviceBasedPushAuthenticationMethod' }).Count -gt 0) - 'Microsoft Auth Push' = $MethodTypes -contains 'microsoftAuthenticatorAuthenticationMethod' # Same as passwordless bool for now - 'Windows Hello' = $MethodTypes -contains 'windowsHelloForBusinessAuthenticationMethod' - 'Microsoft Auth App' = $MethodTypes -contains 'microsoftAuthenticatorAuthenticationMethod' # Same as passwordless bool for now - 'Hardware OTP' = $MethodTypes -contains 'hardwareOathAuthenticationMethod' # Requires detail call not implemented yet - 'Software OTP' = $MethodTypes -contains 'softwareOathAuthenticationMethod' - 'Temporary Pass' = $MethodTypes -contains 'temporaryAccessPassAuthenticationMethod' - #'MacOS Secure Key' = $false # Not directly available - 'SMS' = $Details.PhoneMethods.SmsSignInState -contains 'ready' # Changed from 'enabled' to 'ready' based on docs - 'Email' = $Details.EmailMethods.Count -gt 0 - # Security Questions column: Use the value passed in $Details, default to false if key doesn't exist - 'Security Questions Registered' = $Details.ContainsKey('SecurityQuestionsRegistered') -and $Details['SecurityQuestionsRegistered'] - 'Voice Call' = ($Details.PhoneMethods | Where-Object { $_.PhoneType -eq 'mobile' -or $_.PhoneType -eq 'alternateMobile' -or $_.PhoneType -eq 'office' }).Count -gt 0 # Check specific types - 'Alternative Phone' = ($Details.PhoneMethods | Where-Object { $_.PhoneType -eq 'alternateMobile' }).Count -gt 0 # Specific check - # Method Details - FIDO2Keys = $Details.Fido2Keys - PhoneMethods = $Details.PhoneMethods - EmailMethods = $Details.EmailMethods - MicrosoftAuthenticator = $Details.MicrosoftAuthenticator - TemporaryAccessPass = $Details.TemporaryAccessPass - WindowsHelloForBusiness = $Details.WindowsHelloMethods - SoftwareOathMethods = $Details.SoftwareOath - HardwareOathMethods = $Details.HardwareOath - - # Additional Info - TotalMethodsCount = $AuthMethods.Count - DeviceBoundPushCount = ($AuthMethods | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.deviceBasedPushAuthenticationMethod' }).Count - - CreatedDateTime = $User.CreatedDateTime - } - $resultObject -} + DefaultMfaMethod = $defaultMfaMethod + IsMfaCapable = $MethodTypes -match '(microsoftAuthenticatorAuthenticationMethod|phoneAuthenticationMethod|fido2AuthenticationMethod|softwareOathAuthenticationMethod)' | Sort-Object -Unique + IsPasswordlessCapable = $MethodTypes -match '(fido2AuthenticationMethod|windowsHelloForBusinessAuthenticationMethod)' -and (-not $Details.PasswordMethods) + MethodTypesRegistered = $MethodTypes | Sort-Object -Unique # Renamed for clarity + + # Method Types (Boolean Flags) + 'PasswordMethodRegistered' = $Details.PasswordMethods # This is just a boolean derived earlier + 'Microsoft Auth Passwordless' = $MethodTypes -contains 'microsoftAuthenticatorAuthenticationMethod' # Check specific type + 'FIDO2 Security Key' = $MethodTypes -contains 'fido2AuthenticationMethod' + 'Device Bound PushKey' = $MethodTypes -contains 'deviceBasedPushAuthenticationMethod' # Legacy column retained + 'Device Bound Push' = ($MethodTypes -contains 'deviceBasedPushAuthenticationMethod') -or ( + ($AuthMethods | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.deviceBasedPushAuthenticationMethod' }).Count -gt 0) + 'Microsoft Auth Push' = $MethodTypes -contains 'microsoftAuthenticatorAuthenticationMethod' # Same as passwordless bool for now + 'Windows Hello' = $MethodTypes -contains 'windowsHelloForBusinessAuthenticationMethod' + 'Microsoft Auth App' = $MethodTypes -contains 'microsoftAuthenticatorAuthenticationMethod' # Same as passwordless bool for now + 'Hardware OTP' = $MethodTypes -contains 'hardwareOathAuthenticationMethod' # Requires detail call not implemented yet + 'Software OTP' = $MethodTypes -contains 'softwareOathAuthenticationMethod' + 'Temporary Pass' = $MethodTypes -contains 'temporaryAccessPassAuthenticationMethod' + #'MacOS Secure Key' = $false # Not directly available + 'SMS' = $Details.PhoneMethods.SmsSignInState -contains 'ready' # Changed from 'enabled' to 'ready' based on docs + 'Email' = $Details.EmailMethods.Count -gt 0 + # Security Questions column: Use the value passed in $Details, default to false if key doesn't exist + 'Security Questions Registered' = $Details.ContainsKey('SecurityQuestionsRegistered') -and $Details['SecurityQuestionsRegistered'] + 'Voice Call' = ($Details.PhoneMethods | Where-Object { $_.PhoneType -eq 'mobile' -or $_.PhoneType -eq 'alternateMobile' -or $_.PhoneType -eq 'office' }).Count -gt 0 # Check specific types + 'Alternative Phone' = ($Details.PhoneMethods | Where-Object { $_.PhoneType -eq 'alternateMobile' }).Count -gt 0 # Specific check + # Method Details + FIDO2Keys = $Details.Fido2Keys + PhoneMethods = $Details.PhoneMethods + EmailMethods = $Details.EmailMethods + MicrosoftAuthenticator = $Details.MicrosoftAuthenticator + TemporaryAccessPass = $Details.TemporaryAccessPass + WindowsHelloForBusiness = $Details.WindowsHelloMethods + SoftwareOathMethods = $Details.SoftwareOath + HardwareOathMethods = $Details.HardwareOath + + # Additional Info + TotalMethodsCount = $AuthMethods.Count + DeviceBoundPushCount = ($AuthMethods | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.deviceBasedPushAuthenticationMethod' }).Count + + CreatedDateTime = $User.CreatedDateTime + } + $resultObject +} diff --git a/Public/Get-MyGuest.ps1 b/Public/Get-MyGuest.ps1 index 522e453..cd3e9db 100644 --- a/Public/Get-MyGuest.ps1 +++ b/Public/Get-MyGuest.ps1 @@ -81,6 +81,26 @@ function Get-MyGuest { $LastNonInteractiveSignInDaysAgo = $null } + if ($Guest.SignInActivity -and $Guest.SignInActivity.LastSuccessfulSignInDateTime) { + $LastSuccessfulSignInDaysAgo = [math]::Floor((New-TimeSpan -Start $Guest.SignInActivity.LastSuccessfulSignInDateTime -End $Today).TotalDays) + } else { + $LastSuccessfulSignInDaysAgo = $null + } + + if ($null -ne $LastSignInDaysAgo -and $null -ne $LastNonInteractiveSignInDaysAgo) { + $SignInPattern = 'Interactive + non-interactive' + } elseif ($null -ne $LastSignInDaysAgo) { + $SignInPattern = 'Interactive only' + } elseif ($null -ne $LastNonInteractiveSignInDaysAgo) { + $SignInPattern = 'Non-interactive only' + } elseif ($null -ne $LastSuccessfulSignInDaysAgo) { + $SignInPattern = 'Successful sign-in only' + } elseif ($Guest.SignInActivity) { + $SignInPattern = 'No sign-in recorded' + } else { + $SignInPattern = 'No activity data' + } + $ExternalAddress = $null if ($Guest.OtherMails -and $Guest.OtherMails.Count -gt 0) { $ExternalAddress = $Guest.OtherMails[0] @@ -155,7 +175,11 @@ function Get-MyGuest { LastSignInDaysAgo = $LastSignInDaysAgo LastNonInteractiveSignInDateTime = if ($Guest.SignInActivity) { $Guest.SignInActivity.LastNonInteractiveSignInDateTime } else { $null } LastNonInteractiveSignInDaysAgo = $LastNonInteractiveSignInDaysAgo + LastSuccessfulSignInDateTime = if ($Guest.SignInActivity) { $Guest.SignInActivity.LastSuccessfulSignInDateTime } else { $null } + LastSuccessfulSignInDaysAgo = $LastSuccessfulSignInDaysAgo NeverSignedIn = ($null -eq $LastSignInDaysAgo -and $null -eq $LastNonInteractiveSignInDaysAgo) + NeverSuccessfullySignedIn = ($null -eq $LastSuccessfulSignInDaysAgo) + SignInPattern = $SignInPattern CreationType = $Guest.CreationType CompanyName = $Guest.CompanyName IsSynchronized = if ($Guest.OnPremisesSyncEnabled) { $Guest.OnPremisesSyncEnabled } else { $null } diff --git a/Public/Get-MyUser.ps1 b/Public/Get-MyUser.ps1 index e7de150..99aabbd 100644 --- a/Public/Get-MyUser.ps1 +++ b/Public/Get-MyUser.ps1 @@ -97,10 +97,32 @@ function Get-MyUser { $LastNonInteractiveSignInDaysAgo = $null } + if ($User.SignInActivity -and $User.SignInActivity.LastSuccessfulSignInDateTime) { + $LastSuccessfulSignInDaysAgo = [math]::Floor((New-TimeSpan -Start $User.SignInActivity.LastSuccessfulSignInDateTime -End $Today).TotalDays) + } else { + $LastSuccessfulSignInDaysAgo = $null + } + if ($null -ne $User.SignInActivity) { $NeverSignedIn = ($null -eq $LastSignInDaysAgo -and $null -eq $LastNonInteractiveSignInDaysAgo) + $NeverSuccessfullySignedIn = ($null -eq $LastSuccessfulSignInDaysAgo) } else { $NeverSignedIn = $null + $NeverSuccessfullySignedIn = $null + } + + if ($null -ne $LastSignInDaysAgo -and $null -ne $LastNonInteractiveSignInDaysAgo) { + $SignInPattern = 'Interactive + non-interactive' + } elseif ($null -ne $LastSignInDaysAgo) { + $SignInPattern = 'Interactive only' + } elseif ($null -ne $LastNonInteractiveSignInDaysAgo) { + $SignInPattern = 'Non-interactive only' + } elseif ($null -ne $LastSuccessfulSignInDaysAgo) { + $SignInPattern = 'Successful sign-in only' + } elseif ($null -ne $User.SignInActivity) { + $SignInPattern = 'No sign-in recorded' + } else { + $SignInPattern = 'No activity data' } $UserDomain = $null @@ -138,7 +160,11 @@ function Get-MyUser { LastSignInDaysAgo = $LastSignInDaysAgo LastNonInteractiveSignInDateTime = if ($User.SignInActivity) { $User.SignInActivity.LastNonInteractiveSignInDateTime } else { $null } LastNonInteractiveSignInDaysAgo = $LastNonInteractiveSignInDaysAgo + LastSuccessfulSignInDateTime = if ($User.SignInActivity) { $User.SignInActivity.LastSuccessfulSignInDateTime } else { $null } + LastSuccessfulSignInDaysAgo = $LastSuccessfulSignInDaysAgo NeverSignedIn = $NeverSignedIn + NeverSuccessfullySignedIn = $NeverSuccessfullySignedIn + SignInPattern = $SignInPattern } if ($PerLicense) { @@ -230,6 +256,123 @@ function Get-MyUser { $OutputUser['LicensesErrors'] = $LicensesErrors $OutputUser['Licenses'] = $LicensesList $OutputUser['Plans'] = $Plans + + $IsCloudOnlyMemberCandidate = $User.AccountEnabled -eq $true -and $User.UserType -eq 'Member' -and $User.OnPremisesSyncEnabled -eq $false + $CloudOnlyProfile = $null + $CloudOnlyReviewPriority = $null + $CloudOnlySignals = [System.Collections.Generic.List[string]]::new() + + if ($IsCloudOnlyMemberCandidate) { + $CloudOnlySignals.Add('Enabled member account') + $CloudOnlySignals.Add('Cloud-only identity source') + + if ($User.Manager.Id) { + $CloudOnlySignals.Add('Manager assigned') + } else { + $CloudOnlySignals.Add('No manager assigned') + } + if ($User.GivenName -or $User.SurName) { + $CloudOnlySignals.Add('Given name or surname present') + } else { + $CloudOnlySignals.Add('No given name or surname') + } + if ($User.JobTitle) { + $CloudOnlySignals.Add('Job title present') + } else { + $CloudOnlySignals.Add('No job title') + } + if ($null -ne $LastSignInDaysAgo) { + $CloudOnlySignals.Add('Interactive sign-in observed') + } + if ($null -ne $LastNonInteractiveSignInDaysAgo -and $null -eq $LastSignInDaysAgo) { + $CloudOnlySignals.Add('Only non-interactive sign-in observed') + } + if ($null -ne $LastSuccessfulSignInDaysAgo) { + $CloudOnlySignals.Add("Successful sign-in recorded $LastSuccessfulSignInDaysAgo days ago") + } elseif ($NeverSuccessfullySignedIn -eq $true) { + $CloudOnlySignals.Add('No successful sign-in recorded') + } + if (-not $LicensesList.Count) { + $CloudOnlySignals.Add('No licenses assigned') + } + + $workloadLicenseKeywords = @( + 'teams rooms', + 'meeting room', + 'common area phone', + 'resource account', + 'virtual user' + ) + $hasWorkloadLicenseIndicator = $false + + foreach ($LicenseName in $LicensesList) { + $licenseValue = [string] $LicenseName + $licenseValueLower = $licenseValue.ToLowerInvariant() + foreach ($keyword in $workloadLicenseKeywords) { + if ($licenseValueLower -like "*$keyword*") { + $hasWorkloadLicenseIndicator = $true + $CloudOnlySignals.Add("Workload license: $licenseValue") + break + } + } + if ($hasWorkloadLicenseIndicator) { + break + } + } + + if (-not $hasWorkloadLicenseIndicator) { + foreach ($PlanName in $Plans) { + if (-not $PlanName) { + continue + } + + $planValue = [string] $PlanName + $planValueLower = $planValue.ToLowerInvariant() + foreach ($keyword in $workloadLicenseKeywords) { + if ($planValueLower -like "*$keyword*") { + $hasWorkloadLicenseIndicator = $true + $CloudOnlySignals.Add("Workload plan: $planValue") + break + } + } + if ($hasWorkloadLicenseIndicator) { + break + } + } + } + + $hasHumanIndicators = $false + if ($User.Manager.Id -or $User.GivenName -or $User.SurName -or $User.JobTitle -or $null -ne $LastSignInDaysAgo) { + $hasHumanIndicators = $true + } + + $hasWorkloadBehaviorIndicators = $null -eq $LastSignInDaysAgo -and + $null -ne $LastNonInteractiveSignInDaysAgo -and + -not $User.Manager.Id -and + -not $User.GivenName -and + -not $User.SurName -and + -not $User.JobTitle + + if ($hasWorkloadBehaviorIndicators) { + $CloudOnlySignals.Add('Non-interactive-only activity with no manager or profile attributes') + } + + if ($hasWorkloadLicenseIndicator -or $hasWorkloadBehaviorIndicators) { + $CloudOnlyProfile = 'Likely workload/resource' + $CloudOnlyReviewPriority = 'Low' + } elseif ($hasHumanIndicators -and -not $hasWorkloadLicenseIndicator) { + $CloudOnlyProfile = 'Likely human account' + $CloudOnlyReviewPriority = 'High' + } else { + $CloudOnlyProfile = 'Needs review' + $CloudOnlyReviewPriority = 'Medium' + } + } + + $OutputUser['IsCloudOnlyMemberCandidate'] = $IsCloudOnlyMemberCandidate + $OutputUser['CloudOnlyProfile'] = $CloudOnlyProfile + $OutputUser['CloudOnlyReviewPriority'] = $CloudOnlyReviewPriority + $OutputUser['CloudOnlySignals'] = if ($CloudOnlySignals.Count -gt 0) { $CloudOnlySignals -join ', ' } else { $null } } [PSCustomObject] $OutputUser From cbd10650032d313e44dc93fd9b342cea5baf8d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 13 Apr 2026 11:54:03 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(guests):=20=F0=9F=90=9B=20treat=20missi?= =?UTF-8?q?ng=20sign-in=20telemetry=20as=20unknown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - keep missing guest sign-in activity distinct from true no-successful-sign-in cases - restore successful sign-in guest fields used by the external account report --- Public/Get-MyGuest.ps1 | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Public/Get-MyGuest.ps1 b/Public/Get-MyGuest.ps1 index cd3e9db..146b72b 100644 --- a/Public/Get-MyGuest.ps1 +++ b/Public/Get-MyGuest.ps1 @@ -156,6 +156,14 @@ function Get-MyGuest { } } + if ($null -ne $Guest.SignInActivity) { + $NeverSignedIn = ($null -eq $LastSignInDaysAgo -and $null -eq $LastNonInteractiveSignInDaysAgo) + $NeverSuccessfullySignedIn = ($null -eq $LastSuccessfulSignInDaysAgo) + } else { + $NeverSignedIn = $null + $NeverSuccessfullySignedIn = $null + } + [PSCustomObject] @{ DisplayName = $Guest.DisplayName Id = $Guest.Id @@ -177,8 +185,8 @@ function Get-MyGuest { LastNonInteractiveSignInDaysAgo = $LastNonInteractiveSignInDaysAgo LastSuccessfulSignInDateTime = if ($Guest.SignInActivity) { $Guest.SignInActivity.LastSuccessfulSignInDateTime } else { $null } LastSuccessfulSignInDaysAgo = $LastSuccessfulSignInDaysAgo - NeverSignedIn = ($null -eq $LastSignInDaysAgo -and $null -eq $LastNonInteractiveSignInDaysAgo) - NeverSuccessfullySignedIn = ($null -eq $LastSuccessfulSignInDaysAgo) + NeverSignedIn = $NeverSignedIn + NeverSuccessfullySignedIn = $NeverSuccessfullySignedIn SignInPattern = $SignInPattern CreationType = $Guest.CreationType CompanyName = $Guest.CompanyName From 6d3eee2af3e1e9105c56c9304a299b5615782553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 13 Apr 2026 12:36:41 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(users):=20=F0=9F=90=9B=20include=20null?= =?UTF-8?q?=20sync=20state=20in=20cloud-only=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - treat null OnPremisesSyncEnabled values as cloud-only candidates for member review - add an explicit review signal when sync state is unavailable --- Public/Get-MyUser.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Public/Get-MyUser.ps1 b/Public/Get-MyUser.ps1 index 99aabbd..a1822eb 100644 --- a/Public/Get-MyUser.ps1 +++ b/Public/Get-MyUser.ps1 @@ -257,14 +257,18 @@ function Get-MyUser { $OutputUser['Licenses'] = $LicensesList $OutputUser['Plans'] = $Plans - $IsCloudOnlyMemberCandidate = $User.AccountEnabled -eq $true -and $User.UserType -eq 'Member' -and $User.OnPremisesSyncEnabled -eq $false + $IsCloudOnlyMemberCandidate = $User.AccountEnabled -eq $true -and $User.UserType -eq 'Member' -and $User.OnPremisesSyncEnabled -ne $true $CloudOnlyProfile = $null $CloudOnlyReviewPriority = $null $CloudOnlySignals = [System.Collections.Generic.List[string]]::new() if ($IsCloudOnlyMemberCandidate) { $CloudOnlySignals.Add('Enabled member account') - $CloudOnlySignals.Add('Cloud-only identity source') + if ($null -eq $User.OnPremisesSyncEnabled) { + $CloudOnlySignals.Add('Sync state unavailable, treated as cloud-only candidate') + } else { + $CloudOnlySignals.Add('Cloud-only identity source') + } if ($User.Manager.Id) { $CloudOnlySignals.Add('Manager assigned')