public/Get-MsecEntraGuest.ps1
|
function Get-MsecEntraGuest { <# .SYNOPSIS Guest accounts, whether each one was ever redeemed, and when it was last actually used - the identities in the directory that belong to someone outside the tenant. .DESCRIPTION A guest is a real account in the directory. It can be added to groups, granted access to sites and teams, and consented to apps, and it is governed by whatever Conditional Access covers guests - which in many tenants is less than covers members. The two questions worth asking are whether anyone is behind it and whether anyone still uses it, and neither is on the user object in a form you can read directly. AN INVITATION IS NOT AN ACCOUNT SOMEONE USES. InvitationState 'PendingAcceptance' means the account exists and the invitation was never redeemed - nobody has ever signed in as it, yet it can already hold group membership and permissions granted in anticipation. Measured on one tenant: 107 of 222 guests, 48%, had never redeemed. Those are not users; they are access waiting for someone who never arrived. 'NEVER SIGNED IN' MUST NOT BE READ OFF lastSignInDateTime, WHICH IS INTERACTIVE ONLY. An account signing in daily through refresh tokens or a service integration leaves that field EMPTY while lastNonInteractiveSignInDateTime moves every day. Taking the interactive field alone reports an account in active use as dormant - measured on the same tenant, 111 guests looked dormant that way and 109 genuinely were, so two actively-signing-in accounts would have been proposed for deletion. LastSignInUtc here is the LATEST of the interactive, non-interactive and last-successful timestamps, and LastSignInKind says which one it came from. A TENANT WHERE NO GUEST HAS SIGN-IN DATA IS A PERMISSION PROBLEM, NOT AN IDLE TENANT. Graph omits signInActivity entirely - silently, with no error and no property - when the caller lacks 'AuditLog.Read.All'. Every guest then looks like it has never signed in, which is the single most dangerous way this command could be wrong: it reads as a list of accounts safe to delete. So when not one guest carries sign-in data the command WARNS, and SignInDataAvailable is false on every row. Runs as the msec app. Needs 'User.Read.All' for the accounts and 'AuditLog.Read.All' for the sign-in half; without the latter the account half still works. .PARAMETER PendingOnly Only guests whose invitation was never redeemed. .PARAMETER NeverSignedIn Only guests with no sign-in of any kind. Counts non-interactive activity, so an account used by an integration is not included. .PARAMETER InactiveDays Only guests whose last sign-in is older than this many days. Guests that have never signed in at all are NOT returned - that is -NeverSignedIn, and conflating the two hides which of the pair you are looking at. .PARAMETER Domain Only guests whose mail address is in this domain, e.g. 'contoso.com'. .EXAMPLE Get-MsecEntraGuest | Format-Table DisplayName, InvitationState, LastSignInUtc, Domain .EXAMPLE # Invitations nobody ever redeemed - access granted to an absent person. Get-MsecEntraGuest -PendingOnly .EXAMPLE # Redeemed once, then abandoned. The deletion candidates that are NOT merely pending. Get-MsecEntraGuest -InactiveDays 180 .EXAMPLE # Which organisations hold accounts here, largest first. Get-MsecEntraGuest | Group-Object Domain | Sort-Object Count -Descending .OUTPUTS PSCustomObject per guest, PSTypeName 'MsecEntraGuest'. .NOTES Read-only. Deleting a guest removes its access everywhere at once, which is the point and also the risk: check group membership with Get-MsecEntraGroupMember first, since a guest can be the only external member of something that matters. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [switch] $PendingOnly, [switch] $NeverSignedIn, [ValidateRange(1, 3650)] [int] $InactiveDays, [string] $Domain ) Assert-MsecSession $select = @( 'id', 'displayName', 'userPrincipalName', 'mail', 'accountEnabled', 'createdDateTime', 'externalUserState', 'externalUserStateChangeDateTime', 'creationType', 'companyName', 'signInActivity' ) -join ',' try { $guests = @(Invoke-MsecGraphRequest -All ` -Path "/v1.0/users?`$filter=userType eq 'Guest'&`$select=$select&`$top=999") } catch { if ($_.Exception.Message -match '403|Forbidden') { throw ("Forbidden listing guests. The msec app needs 'User.Read.All', and " + "'AuditLog.Read.All' for the sign-in columns. Original error: $($_.Exception.Message)") } throw } # Graph drops signInActivity silently when AuditLog.Read.All is missing - no error, no # property. If NOT ONE guest has it, that is the permission, not an idle tenant, and the # difference decides whether this output is a deletion list or a false one. $withSignIn = @($guests | Where-Object { $_.signInActivity }).Count $signInAvailable = $withSignIn -gt 0 if ($guests.Count -gt 0 -and -not $signInAvailable) { Write-Warning ("No guest carries sign-in data, so every row below reports LastSignInUtc " + "as null. This is NOT evidence that no guest has signed in: Graph omits " + "signInActivity entirely, without error, when the caller lacks 'AuditLog.Read.All'. " + 'Grant that permission before treating any of these as unused.') } $now = [datetime]::UtcNow foreach ($g in $guests) { $mail = [string]$g.mail if (-not $mail) { $mail = [string]$g.userPrincipalName } # A guest UPN is mangled - alex_contoso.com#EXT#@tenant.onmicrosoft.com - so the home # domain comes from mail, falling back to unpicking the UPN. # # NOT $domain: PowerShell variable names are case-insensitive, so a local $domain IS # the -Domain parameter, and the first row would overwrite the filter with its own # value - after which -Domain matches only guests from the first guest's domain. $guestDomain = if ($mail -match '@') { ($mail -split '@')[-1] } elseif ($mail -match '_') { ($mail -split '#EXT#')[0] -replace '.*_', '' } else { '' } if ($Domain -and $guestDomain -ne $Domain) { continue } $state = [string]$g.externalUserState if ($PendingOnly -and $state -ne 'PendingAcceptance') { continue } # The LATEST of the three, not the interactive one - see the description. $stamps = @( $g.signInActivity.lastSignInDateTime $g.signInActivity.lastNonInteractiveSignInDateTime $g.signInActivity.lastSuccessfulSignInDateTime ) | Where-Object { $_ } $lastSignIn = $null if ($stamps) { $lastSignIn = ($stamps | ForEach-Object { [datetime]$_ } | Sort-Object -Descending)[0] } # Which signal it came from, so an account that is only ever non-interactive is # visible as that rather than silently counted as a normal sign-in. $kind = if (-not $lastSignIn) { 'None' } elseif ($g.signInActivity.lastSignInDateTime -and [datetime]$g.signInActivity.lastSignInDateTime -eq $lastSignIn) { 'Interactive' } else { 'NonInteractive' } if ($NeverSignedIn -and $lastSignIn) { continue } # Never-signed-in is deliberately NOT inactive-for-N-days: they are different findings # and merging them hides which one a row represents. if ($InactiveDays) { if (-not $lastSignIn) { continue } if ($lastSignIn -gt $now.AddDays(-$InactiveDays)) { continue } } $invited = $null if ($g.createdDateTime) { $invited = [datetime]$g.createdDateTime } [PSCustomObject]@{ PSTypeName = 'MsecEntraGuest' DisplayName = [string]$g.displayName Mail = $mail Domain = $guestDomain UserPrincipalName = [string]$g.userPrincipalName # 'PendingAcceptance' means nobody ever redeemed the invitation. InvitationState = $state AccountEnabled = [bool]$g.accountEnabled InvitedUtc = $invited DaysSinceInvite = if ($invited) { [int]($now - $invited).TotalDays } else { $null } LastSignInUtc = $lastSignIn LastSignInKind = $kind DaysSinceSignIn = if ($lastSignIn) { [int]($now - $lastSignIn).TotalDays } else { $null } # False means the sign-in columns could not be read at all, NOT that the account # has never been used. SignInDataAvailable = $signInAvailable CompanyName = [string]$g.companyName CreationType = [string]$g.creationType Id = [string]$g.id Raw = $g } } } |