Forum Discussion

LeonPavesic's avatar
LeonPavesic
Silver Contributor
Mar 18, 2024
Solved

PowerShell - GraphAPI - OneDrive Shared files - Error

Hello everybody,

for changing the UPN for some number of users I need to export all the shared files in OneDrive for all users in the tenant.

I have found an excellent script by the MVP Vasil Michev on the GitHub (PowerShell/Graph_ODFB_shared_files.ps1 at master · michevnew/PowerShell · GitHub). The script uses Entra ID App Registration with API permissions, GraphAPI and Powershell. 

I needed to change the script because it didn't export all the files, just some of them and i wanted to have a .csv export. After I changed the script, I get all the shared files, but the scripts stops after exporting the files from about half of my users (stops by the letter m in the UPN).

This is the error that I am getting (for all the user until m in UPN the results are ok):
ConvertFrom-Json : Cannot bind argument to parameter 'InputObject' because it is null. At C:\scripts\OneDrive_revised3.ps1:255 char:30 + return $result.Content | ConvertFrom-Json

Is it an issue with a GraphAPI limitation (to many requests) or something else? How can I resolve this? Can I use a list of some users (from a csv file) to export the data only for these users?

Here is the moddified script (I deleted Entra ID secrets and IDs):

 


[CmdletBinding()] #Make sure we can use -Verbose
Param([switch]$ExpandFolders,[int]$depth)

function processChildren {

    Param(
    #Graph User object
    [Parameter(Mandatory=$true)]$User,
    #URI for the drive
    [Parameter(Mandatory=$true)][string]$URI,
    #Use the ExpandFolders switch to specify whether to expand folders and include their items in the output.
    [switch]$ExpandFolders,
    #Use the Depth parameter to specify the folder depth for expansion/inclusion of items.
    [int]$depth)

    $URI = "$URI/children"
    $children = @()
    #fetch children, make sure to handle multiple pages
    do {
        $result = Invoke-GraphApiRequest -Uri "$URI" -Verbose:$VerbosePreference
        $URI = $result.'@odata.nextLink'
        #If we are getting multiple pages, add some delay to avoid throttling
        Start-Sleep -Milliseconds 500
        $children += $result
    } while ($URI)
    if (!$children) { Write-Verbose "No items found for $($user.userPrincipalName), skipping..."; continue }

    #handle different children types
    $output = @()
    $cFolders = $children.value | ? {$_.Folder}
    $cFiles = $children.value | ? {$_.File} #doesnt return notebooks
    $cNotebooks = $children.value | ? {$_.package.type -eq "OneNote"}

    #Process Folders
    foreach ($folder in $cFolders) {
        $output += (processFolder -User $User -folder $folder -ExpandFolders:$ExpandFolders -depth $depth -Verbose:$VerbosePreference)
    }

    #Process Files
    foreach ($file in $cFiles) {
        if ($file.shared) {
            $output += (processFile -User $User -file $file -Verbose:$VerbosePreference)
        }
    }

    #Process Notebooks
    foreach ($notebook in $cNotebooks) {
        if ($notebook.shared) {
            $output += (processFile -User $User -file $notebook -Verbose:$VerbosePreference)
        }
    }

    return $output
}

function processFolder {

    Param(
    #Graph User object
    [Parameter(Mandatory=$true)]$User,
    #Folder object
    [Parameter(Mandatory=$true)]$folder,
    #Use the ExpandFolders switch to specify whether to expand folders and include their items in the output.
    [switch]$ExpandFolders,
    #Use the Depth parameter to specify the folder depth for expansion/inclusion of items.
    [int]$depth)

    #prepare the output object
    $fileinfo = New-Object psobject
    $fileinfo | Add-Member -MemberType NoteProperty -Name "OneDriveOwner" -Value $user.userPrincipalName
    $fileinfo | Add-Member -MemberType NoteProperty -Name "Name" -Value $folder.name
    $fileinfo | Add-Member -MemberType NoteProperty -Name "ItemType" -Value "Folder"
    $fileinfo | Add-Member -MemberType NoteProperty -Name "Shared" -Value (&{If($folder.shared) {"Yes"} Else {"No"}})

    #if the Shared property is set, fetch permissions
    if ($folder.shared) {
        $permlist = getPermissions $user.id $folder.id -Verbose:$VerbosePreference

        #Match user entries against the list of domains in the tenant to populate the ExternallyShared value
        $regexmatches = $permlist | % {if ($_ -match "\(?\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*\)?") {$Matches[0]}}
        if ($permlist -match "anonymous") { $fileinfo | Add-Member -MemberType NoteProperty -Name "ExternallyShared" -Value "Yes" }
        else {
            if (!$domains) { $fileinfo | Add-Member -MemberType NoteProperty -Name "ExternallyShared" -Value "No domain info" }
            elseif ($regexmatches -notmatch ($domains -join "|")) { $fileinfo | Add-Member -MemberType NoteProperty -Name "ExternallyShared" -Value "Yes" }
            else { $fileinfo | Add-Member -MemberType NoteProperty -Name "ExternallyShared" -Value "No" }
        }
        $fileinfo | Add-Member -MemberType NoteProperty -Name "Permissions" -Value ($permlist -join ",")
    }
    $fileinfo | Add-Member -MemberType NoteProperty -Name "ItemPath" -Value $folder.webUrl

    #Since this is a folder item, check for any children, depending on the script parameters
    if (($folder.folder.childCount -gt 0) -and $ExpandFolders -and ((3 - $folder.parentReference.path.Split("/").Count + $depth) -gt 0)) {
        Write-Verbose "Folder $($folder.Name) has child items"
        $uri = "https://graph.microsoft.com/v1.0/users/$($user.id)/drive/items/$($folder.id)"
        $folderItems = processChildren -User $user -URI $uri -ExpandFolders:$ExpandFolders -depth $depth -Verbose:$VerbosePreference
    }

    #handle the output
    if ($folderItems) { $f = @(); $f += $fileinfo; $f += $folderItems; return $f }
    else { return $fileinfo }
}

function processFile {

    Param(
    #Graph User object
    [Parameter(Mandatory=$true)]$User,
    #File object
    [Parameter(Mandatory=$true)]$file)

    #prepare the output object
    $fileinfo = New-Object psobject
    $fileinfo | Add-Member -MemberType NoteProperty -Name "OneDriveOwner" -Value $user.userPrincipalName
    $fileinfo | Add-Member -MemberType NoteProperty -Name "Name" -Value $file.name
    $fileinfo | Add-Member -MemberType NoteProperty -Name "ItemType" -Value (&{If($file.package.Type -eq "OneNote") {"Notebook"} Else {"File"}})
    $fileinfo | Add-Member -MemberType NoteProperty -Name "Shared" -Value (&{If($file.shared) {"Yes"} Else {"No"}})

    #if the Shared property is set, fetch permissions
    if ($file.shared) {
        $permlist = getPermissions $user.id $file.id -Verbose:$VerbosePreference

        #Match user entries against the list of domains in the tenant to populate the ExternallyShared value
        $regexmatches = $permlist | % {if ($_ -match "\(?\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*\)?") {$Matches[0]}}
        if ($permlist -match "anonymous") { $fileinfo | Add-Member -MemberType NoteProperty -Name "ExternallyShared" -Value "Yes" }
        else {
            if (!$domains) { $fileinfo | Add-Member -MemberType NoteProperty -Name "ExternallyShared" -Value "No domain info" }
            elseif ($regexmatches -notmatch ($domains -join "|")) { $fileinfo | Add-Member -MemberType NoteProperty -Name "ExternallyShared" -Value "Yes" }
            else { $fileinfo | Add-Member -MemberType NoteProperty -Name "ExternallyShared" -Value "No" }
        }
        $fileinfo | Add-Member -MemberType NoteProperty -Name "Permissions" -Value ($permlist -join ",")
    }
    $fileinfo | Add-Member -MemberType NoteProperty -Name "ItemPath" -Value $file.webUrl

    #handle the output
    return $fileinfo
}

function getPermissions {

    Param(
    #Use the UserId parameter to provide an unique identifier for the user object.
    [Parameter(Mandatory=$true)][ValidateNotNullOrEmpty()][string]$UserId,
    #Use the ItemId parameter to provide an unique identifier for the item object.
    [Parameter(Mandatory=$true)][ValidateNotNullOrEmpty()][string]$ItemId)

    #fetch permissions for the given item
    $uri = "https://graph.microsoft.com/beta/users/$($UserId)/drive/items/$($ItemId)/permissions"
    $permissions = (Invoke-GraphApiRequest -Uri $uri -Verbose:$VerbosePreference).Value

    #build the permissions string
    $permlist = @()
    foreach ($entry in $permissions) {
        #Sharing link
        if ($entry.link) {
            $strPermissions = $($entry.link.type) + ":" + $($entry.link.scope)
            if ($entry.grantedToIdentitiesV2) { $strPermissions = $strPermissions + " (" + (((&{If($entry.grantedToIdentitiesV2.siteUser.email) {$entry.grantedToIdentitiesV2.siteUser.email} else {$entry.grantedToIdentitiesV2.User.email}}) | select -Unique) -join ",") + ")" }
            if ($entry.hasPassword) { $strPermissions = $strPermissions + "[PasswordProtected]" }
            if ($entry.link.preventsDownload) { $strPermissions = $strPermissions + "[BlockDownloads]" }
            if ($entry.expirationDateTime) { $strPermissions = $strPermissions + " (Expires on: $($entry.expirationDateTime))" }
            $permlist += $strPermissions
        }
        #Invitation
        elseif ($entry.invitation) { $permlist += $($entry.roles) + ":" + $($entry.invitation.email) }
        #Direct permissions
        elseif ($entry.roles) {
            if ($entry.grantedToV2.siteUser.Email) { $roleentry = $entry.grantedToV2.siteUser.Email }
            elseif ($entry.grantedToV2.User.Email) { $roleentry = $entry.grantedToV2.User.Email }
            #else { $roleentry = $entry.grantedToV2.siteUser.DisplayName }
            else { $roleentry = $entry.grantedToV2.siteUser.loginName } #user claim
            $permlist += $($entry.Roles) + ':' + $roleentry #apparently the email property can be empty...
        }
        #Inherited permissions
        elseif ($entry.inheritedFrom) { $permlist += "[Inherited from: $($entry.inheritedFrom.path)]" } #Should have a Roles facet, thus covered above
        #some other permissions?
        else { Write-Verbose "Permission $entry not covered by the script!"; $permlist += $entry }
    }

    #handle the output
    return $permlist
}

function Renew-Token {
    #prepare the request
    $url = 'https://login.microsoftonline.com/' + $tenantId + '/oauth2/v2.0/token'

    $Scopes = New-Object System.Collections.Generic.List[string]
    $Scope = "https://graph.microsoft.com/.default"
    $Scopes.Add($Scope)

    $body = @{
        grant_type = "client_credentials"
        client_id = $appID
        client_secret = $client_secret
        scope = $Scopes
    }

    try {
        Set-Variable -Name authenticationResult -Scope Global -Value (Invoke-WebRequest -Method Post -Uri $url -Debug -Verbose -Body $body -ErrorAction Stop)
        $token = ($authenticationResult.Content | ConvertFrom-Json).access_token
    }
    catch { $_; return }

    if (!$token) { Write-Host "Failed to aquire token!"; return }
    else {
        Write-Verbose "Successfully acquired Access Token"

        #Use the access token to set the authentication header
        Set-Variable -Name authHeader -Scope Global -Value @{'Authorization'="Bearer $token";'Content-Type'='application\json'}
    }
}

function Invoke-GraphApiRequest {
    param(
    [Parameter(Mandatory=$true)][ValidateNotNullOrEmpty()][string]$Uri
    )

    if (!$AuthHeader) { Write-Verbose "No access token found, aborting..."; throw }

    try { $result = Invoke-WebRequest -Headers $AuthHeader -Uri $uri -Verbose:$VerbosePreference -ErrorAction Stop }
    catch [System.Net.WebException] {
        if ($_.Exception.Response -eq $null) { throw }

        #Get the full error response
        $streamReader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
        $streamReader.BaseStream.Position = 0
        $errResp = $streamReader.ReadToEnd() | ConvertFrom-Json
        $streamReader.Close()

        if ($errResp.error.code -match "ResourceNotFound|Request_ResourceNotFound") { Write-Verbose "Resource $uri not found, skipping..."; return } #404, continue
        #also handle 429, throttled (Too many requests)
        elseif ($errResp.error.code -eq "BadRequest") { return } #400, we should terminate... but stupid Graph sometimes returns 400 instead of 404
        elseif ($errResp.error.code -eq "Forbidden") { Write-Verbose "Insufficient permissions to run the Graph API call, aborting..."; throw } #403, terminate
        elseif ($errResp.error.code -eq "InvalidAuthenticationToken") {
            if ($errResp.error.message -eq "Access token has expired.") { #renew token, continue
                Write-Verbose "Access token has expired, trying to renew..."
                Renew-Token

                if (!$AuthHeader) { Write-Verbose "Failed to renew token, aborting..."; throw }
                #Token is renewed, retry the query
                $result = Invoke-GraphApiRequest -Uri $uri -Verbose:$VerbosePreference
            }
        }
        else { Write-Verbose "Unexpected error: $errResp"; return }
    }

    #handle the output
    return $result.Content | ConvertFrom-Json
}

#Main script body

#Make sure to define all the required variables
$tenantId = ""
$appID = ""
$client_secret = ""

#Use the ExpandFolders switch to specify whether to expand folders and include their items in the output.
$ExpandFolders = $true

#Use the Depth parameter to specify the folder depth for expansion/inclusion of items.
$depth = 5

#Define the list of domains in the tenant
$domains = @("contoso.com", "example.com")

#Renew the token to ensure it's valid
Renew-Token

#Get all users in the tenant
$users = Invoke-GraphApiRequest -Uri "https://graph.microsoft.com/v1.0/users?`$top=999" -Verbose:$VerbosePreference

#Process each user
foreach ($user in $users.value) {
    Write-Host "Processing $($user.userPrincipalName) OneDrive..."
    #Define the URI for the user's drive
    $uri = "https://graph.microsoft.com/v1.0/users/$($user.id)/drive/root"
    #Fetch items for the user's drive
    $items = processChildren -User $user -URI $uri -ExpandFolders:$ExpandFolders -depth $depth
    #Output the items for the user
    $sharedItems = $items | Where-Object { $_.Shared -eq "Yes" }
    if ($sharedItems.Count -gt 0) {
        $sharedItems | Export-Csv -Path "C:\temp\$($user.userPrincipalName)_OneDriveItems.csv" -NoTypeInformation -Force
    }
}

Write-Host "Export completed."

 

 


Kindest regards,


Leon Pavesic
(LinkedIn)
(Twitter)

  • LeonPavesic 

     

    As an afterthought, the Renew-Token function is flawed and can lead to the unexpected outcome of re-trying a query despite the token renewal having failed.

     

    Given how the function has been used on line 237 and impacts lines 239 to 241, it's quite possible that the token renewal has failed, yet because the $authHeader variable has at no stage been removed, that the wrong conclusion is reached on line 239 - where the pre-existing header featuring the since-expired token remains in existence.

     

    The "easiest" solution to this is to remove the $authHeader variable on the first line of the Renew-Token function using the Remove-Variable commandlet. That way, only a successful renewal will result in its recreation later in the function.

     

    You'd then have to check for any knock-on impacts, but at least in the specific case of lines 239 to 241, the outcome would be fine, and indeed, expected.

     

    As the script is now though, line 239 will always evaluate to $false, meaning line 241 will always execute, even in the token renewal failure scenario.

     

    Cheers,

    Lain

  • LainRobertson's avatar
    LainRobertson
    Silver Contributor

    LeonPavesic 

     

    As an afterthought, the Renew-Token function is flawed and can lead to the unexpected outcome of re-trying a query despite the token renewal having failed.

     

    Given how the function has been used on line 237 and impacts lines 239 to 241, it's quite possible that the token renewal has failed, yet because the $authHeader variable has at no stage been removed, that the wrong conclusion is reached on line 239 - where the pre-existing header featuring the since-expired token remains in existence.

     

    The "easiest" solution to this is to remove the $authHeader variable on the first line of the Renew-Token function using the Remove-Variable commandlet. That way, only a successful renewal will result in its recreation later in the function.

     

    You'd then have to check for any knock-on impacts, but at least in the specific case of lines 239 to 241, the outcome would be fine, and indeed, expected.

     

    As the script is now though, line 239 will always evaluate to $false, meaning line 241 will always execute, even in the token renewal failure scenario.

     

    Cheers,

    Lain

    • LeonPavesic's avatar
      LeonPavesic
      Silver Contributor

      Hi LainRobertson,

      thank you for your instructions how to get the script working.

      I have made some changes (as you mentioned), added some of mine changes and I get the script to work for 999 users in the tenant which is more than enough for me.

      Thanks once again, I am marking your response as the best one.

      Kindest regards

      Leon

  • LainRobertson's avatar
    LainRobertson
    Silver Contributor

    LeonPavesic 

     

    Hi, Leon.

     

    The error relates to the return statement on line 248 of the script (per your post), meaning either $Result, or $Result.Content is $null.

     

    $Result is only set either on lines 220 or 241. So, the underlying issue is how is $Result (or $Result.Content) ending up as $null.

     

    You can choose to dig into that, or you can simply alter line 248 to test for $null rather than blindly trying to pipe $null into ConvertFrom-Json, which is what's triggered that error. Certainly, this would at least be the easiest starting point.

     

    I'd use a simple if block, since it would then be trivial to use a matching else to diagnose what's going on with $Result/$Result.Content.

     

        if ($Result -and $Result.Content)
        {
            return($Result.Content | ConvertFrom-Json);
        }
        else
        {
            [PSCustomObject] @{
                ResultIsNull = $null -eq $Result;
                ContentIsNull = $true;
                Uri = $Uri;
                Result = if ($Result) { $Result; };
            } | Write-Warning;  # Sending this to warning stream to avoid unintended consequences.
        }

     

    (Change the else block as you see fit - I took the lazy approach of dumping it out to the warning stream but to file might provide better fidelity in the scenario where only $Result.Content is $null.)

     

    Cheers,

    Lain

Resources