External Collaboration Settings

Depending upon the configured External Collaboration settings, an Azure B2B (Guest) may join a tenant via a number of ways including invitation from:

  • the Azure Portal
  • Microsoft Teams
  • SharePoint; or
  • A self-service user flow (where configured)

Lifecycle Attributes

Upon invitation, a stub account (or reference) will be created.

This reference will continue to exist even if the host tenant removes the account.

Of use to identity specialists auditing these accounts are the following set of attributes:

  • displayName
  • userPrincipalName
  • signInActivity
  • createdDateTime
  • creationType
  • externalUserState
  • externalUserStateChangeDateTime

Using these, a lifecycle state and usage pattern may be inferred.

Auditing Invitations

An inherent issue with Guest accounts, is ascertaining the exact method of invitation. Often the responsible party is obscured or unavailable on the object’s attributes.

To resolve this, you may gather additional information from the Azure AD audit logs. These, when combined with the Guest, supplement the lifecycle attributes providing the:

  • InitiatingActorType: Application or User
  • InitiatingActor: UserPrincipalName (DisplayName)

PowerShell Script

The following script gathers the core set of lifecycle attributes, together with the relevant audit log events.

Sample Output

Id                                : ...
DisplayName                       : Chris Dymond
UserPrincipalName                 : chris.dymond_somedomain.com.au#EXT#@<yourtenant>.onmicrosoft.com
CreatedDateTime                   : 6/10/2021 4:42:46 PM
CreationType                      : Invitation
ExternalUserState                 : Accepted
ExternalUserStateChangeDateTime   : 6/10/2021 4:46:28 PM
LastSignInDateTime                :
LastSignInRequestId               :
LastNonInteractiveSignInDateTime  :
LastNonInteractiveSignInRequestId :
InitiatingActorType               : User
InitiatingActor                   : John.Smith@somecompany.com.au

Id                                : ...
DisplayName                       : Jack Johnson
UserPrincipalName                 : jjohnson_anotherdomain.com#EXT#@<yourtenant>.onmicrosoft.com
CreatedDateTime                   : 6/10/2021 10:30:05 PM
CreationType                      :
ExternalUserState                 :
ExternalUserStateChangeDateTime   :
LastSignInDateTime                : 7/10/2021 4:45:25 PM
LastSignInRequestId               : ...
LastNonInteractiveSignInDateTime  : 7/10/2021 7:11:11 PM
LastNonInteractiveSignInRequestId : ...
InitiatingActorType               : Application
InitiatingActor                   : Office 365 SharePoint Online

Code

This snippet depends upon the Azure AD or AzureADPreview PowerShell modules.

Note that without a logging solution audit events (capturing the Actor of the invitation) are limited to the last 30 days.

using namespace System.Collections.Generic
using namespace Microsoft.Open.Azure.AD.CommonLibrary
using namespace Microsoft.Open.AzureAD.Model
$ErrorActionPreference = "Stop"

# This is for calling Graph Directly using Azure AD PS module token - it is here for completeness.
function Send-MSGraphGetRequest {
    [CmdletBinding()]
    [OutputType([List[PSCustomObject]])]
    param
    (
        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0)]
        [String] $GraphUri
    )
    process {
        Get-AzureADMSAdministrativeUnit -Top 1 | Out-Null # Just to ensure we have a graph.microsoft.com token (not just a graph.windows.net one)
        $graphToken = [AzureSession]::TokenCache.ReadItems() | `
            Where-Object { $_.Resource -eq 'https://graph.microsoft.com' } | Select-Object AccessToken
        if ($null -eq $graphToken) {
            throw "The Graph Access token is not available!"
        }
        $graphRequest = @{
            Uri     = $GraphUri
            Headers = @{
                'Authorization' = "Bearer $($graphToken.AccessToken)" 
            }
            Method  = 'GET'
        }
        $resultList = [List[PSCustomObject]]::new()
        try {
            $response = Invoke-RestMethod @graphRequest
            if ($response.Value.Count -eq 0) {
                $resultList.Add($response)
            }
            else {
                $response.Value | ForEach-Object {
                    $resultList.Add($_)
                }
                while ($null -ne $response.'@odata.nextLink') {
                    $graphRequest.Uri = $response.'@odata.nextLink' 
                    $response = Invoke-RestMethod @graphRequest
                    if ($response.Value.Count -eq 0) {
                        $resultList.Add($response)
                    }
                    else {
                        $response.Value | ForEach-Object {
                            $resultList.Add($_)
                        }   
                    }
                }
            }
        }
        catch {
            $respStream = $_.Exception.Response.GetResponseStream()
            $reader = New-Object System.IO.StreamReader($respStream)
            $errorBody = $reader.ReadToEnd() | ConvertFrom-Json | Select-Object -ExpandProperty error
            $errorCode = $errorBody | Select-Object -ExpandProperty code
            $errorMessage = $errorBody | Select-Object -ExpandProperty message
            throw "$errorCode`n$errorMessage"
        }
        
        $resultList
    }
}

Write-Host "Getting Guest Users..."

# Filter for Guest accounts and their relevant attributes
$graphUri = "
    https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'
    &`$select=displayName,userPrincipalName,signInActivity,createdDateTime,creationType,
    externalUserState,externalUserStateChangeDateTime
    "
$guestUsers = Send-MSGraphGetRequest $graphUri

Write-Host "Getting audits for Core Directory with Success and the Add User activity..."

# Unless you have a seperate logging solution you are limited to audits from the last 30 days for P1/P2.
$graphUri = "
https://graph.microsoft.com/beta/auditLogs/directoryAudits?`$filter=loggedByService eq 'Core Directory' and result eq 'success' and activityDisplayName eq 'Add user'"
$directoryAuditsForAddUser = Send-MSGraphGetRequest $graphUri
$directoryAuditsForAddUserDict = [Dictionary[String, Object]]::new()
$directoryAuditsForAddUser | ForEach-Object {
    if ($_.targetResources.Count -gt 1) {
        Throw "The target resource count should not exceeed 1."
    }
    $directoryAuditsForAddUserDict.Add($_.targetResources.id, $_)   
}

Write-Host "Getting audits for Invited Users with Success and the Invite external user activity..."

$graphUri = "
https://graph.microsoft.com/beta/auditLogs/directoryAudits?`$filter=loggedByService eq 'Invited Users' and result eq 'success' and activityDisplayName eq 'Invite external user'"
$directoryAuditsForInvitedUsers = Send-MSGraphGetRequest $graphUri
$directoryAuditsForInvitedUsersDict = [Dictionary[String, Object]]::new()
$directoryAuditsForInvitedUsers | ForEach-Object {
    if ($_.targetResources.Count -gt 1) {
        Throw "The target resource count should not exceeed 1."
    }
    if ($null -eq $directoryAuditsForInvitedUsersDict[$_.targetResources.id]) {
        $directoryAuditsForInvitedUsersDict.Add($_.targetResources.id, $_)   
    }
    else {
        # A person may be sent multiple invites until accepted
        # For our purposes the eldest audit record will indicate the User
        $directoryAuditsForInvitedUsersDict[$_.targetResources.id] = $_
    }    
}

class GuestUser {
    [string]$Id
    [string]$DisplayName
    [string]$UserPrincipalName
    [Nullable[DateTime]]$CreatedDateTime
    [string]$CreationType
    [string]$ExternalUserState
    [Nullable[DateTime]]$ExternalUserStateChangeDateTime
    [Nullable[DateTime]]$LastSignInDateTime
    [string]$LastSignInRequestId
    [Nullable[DateTime]]$LastNonInteractiveSignInDateTime
    [string]$LastNonInteractiveSignInRequestId
    [string]$InitiatingActorType
    [string]$InitiatingActor

}

$guestUserList = [List[Object]]::new()

$guestUsers | ForEach-Object {
    $guestUser = [GuestUser]::new()
    $guestUser.Id = $_.id
    $guestUser.DisplayName = $_.displayName
    $guestUser.UserPrincipalName = $_.userPrincipalName
    $guestUser.CreatedDateTime = [DateTime]::ParseExact($_.createdDateTime, "yyyy-MM-ddTHH:mm:ssZ", $null)
    $guestUser.CreationType = $_.creationType
    $guestUser.ExternalUserState = $_.externalUserState
    if ($_.externalUserStateChangeDateTime -ne $null -and $_.externalUserStateChangeDateTime -ne '0001-01-01T00:00:00Z') { 
        $guestUser.ExternalUserStateChangeDateTime = [DateTime]::ParseExact($_.externalUserStateChangeDateTime, "yyyy-MM-ddTHH:mm:ssZ", $null)
    }
    if ($_.signInActivity.lastSignInDateTime -ne $null -and $_.signInActivity.lastSignInDateTime -ne '0001-01-01T00:00:00Z') {
        $guestUser.LastSignInDateTime = [DateTime]::ParseExact($_.signInActivity.lastSignInDateTime, "yyyy-MM-ddTHH:mm:ssZ", $null)
    }  
    $guestUser.LastSignInRequestId = $_.signInActivity.lastSignInRequestId
    if ($_.signInActivity.lastNonInteractiveSignInDateTime -ne $null -and $_.signInActivity.lastNonInteractiveSignInDateTime -ne '0001-01-01T00:00:00Z') {
        $guestUser.LastNonInteractiveSignInDateTime = [DateTime]::ParseExact($_.signInActivity.lastNonInteractiveSignInDateTime, "yyyy-MM-ddTHH:mm:ssZ", $null)
    }  
    $guestUser.LastNonInteractiveSignInRequestId = $_.signInActivity.lastNonInteractiveSignInRequestId

    $audit = $directoryAuditsForInvitedUsersDict[$_.id]
    if ($null -ne $audit) {
        if ($audit.initiatedBy.User -ne $null) {
            $guestUser.InitiatingActorType = 'User'
            $guestUser.InitiatingActor = $audit.initiatedBy.User.userPrincipalName
        }
        elseif (($audit.initiatedBy.App -ne $null)) {
            $guestUser.InitiatingActorType = 'Application'
            $guestUser.InitiatingActor = $audit.initiatedBy.App.DisplayName
        }
    }
    else {
        # Try the Core Directory (ie SharePoint initiated invites)
        $audit = $directoryAuditsForAddUserDict[$_.id]
        if ($null -ne $audit) {
            if ($audit.initiatedBy.User -ne $null) {
                $guestUser.InitiatingActorType = 'User'
                $guestUser.InitiatingActor = $audit.initiatedBy.User.userPrincipalName `
                    + " ($($audit.initiatedBy.User.displayName))"
            }
            elseif (($audit.initiatedBy.App -ne $null)) {
                $guestUser.InitiatingActorType = 'Application'
                $guestUser.InitiatingActor = $audit.initiatedBy.App.DisplayName
            }
        }
    }
    $guestUserList.Add($guestUser)
}
$guestUserList
# $guestUserList | Export-Csv -NoTypeInformation -Path '.YourFileName.csv'