UCLobbySharePoint.psm1
function Get-UcUPNFromString { param ( [string]$InputStr ) $regexUPN = "([^|]+@[a-zA-Z0-9_\-\.]+\.[a-zA-Z]*)" try { $RegexTemp = [regex]::Match($InputStr, $regexUPN).captures.groups if ($RegexTemp.Count -ge 2) { $outUPN = $RegexTemp[1].value } return $outUPN } catch { return "" } } function Invoke-UcGraphRequest { <# .SYNOPSIS Invoke a Microsoft Graph Request using Entra Auth or Microsoft.Graph.Authentication .DESCRIPTION This function will send a Microsoft Graph request to an available connections, "Test-UcServiceConnection -Type MsGraph" will have to be executed first to determine if we have a session with EntraAuth or Microsoft.Graph.Authentication. Requirements: EntraAuth PowerShell module (Install-Module EntraAuth) or Microsoft Graph Authentication PowerShell Module (Install-Module Microsoft.Graph.Authentication) .PARAMETER Path Specifies Microsoft Graph Path that we want to send the request. .PARAMETER Header Specify the header for cases we need to have a custom header. .PARAMETER Requests If wwe want to send a batch request. .PARAMETER Beta When present, it will use the Microsoft Graph Beta API. .PARAMETER IncludeBody Some Ms Graph APIs can require specific AuthType, Application or Delegated (User). .PARAMETER Activity For Batch requests we have use this for Activity Progress. #> param( [string]$Path = "/`$batch", [object]$Header, [object]$Requests, [switch]$Beta, [switch]$IncludeBody, [string]$Activity ) #This is an easy way to switch between v1.0 and beta. $BatchPath = "`$batch" if ($Beta) { $Path = "../beta" + $Path $BatchPath = "../beta/`$batch" } #If requests then we need to do a batch request to Graph. if (!$Requests) { if ($script:GraphEntraAuth) { if ($Header) { return Invoke-EntraRequest -Path $Path -NoPaging -Header $Header } return Invoke-EntraRequest -Path $Path -NoPaging } else { if ($Header) { $GraphResponse = Invoke-MgGraphRequest -Uri ("/v1.0/" + $Path) -Headers $Header } else { $GraphResponse = Invoke-MgGraphRequest -Uri ("/v1.0/" + $Path) } #When it's more than one result Invoke-MgGraphRequest returns "value", we need to remove it to match EntraAuth behaviour. if ($GraphResponse.value) { return $GraphResponse.value } else { return $GraphResponse } } } else { $outBatchResponses = [System.Collections.ArrayList]::new() $tmpGraphRequests = [System.Collections.ArrayList]::new() $g = 1 $requestHeader = New-Object 'System.Collections.Generic.Dictionary[string, string]' $requestHeader.Add("Content-Type", "application/json") #If activity is null then we can use this to get the function that call this function. if (!($Activity)) { $Activity = [string]$(Get-PSCallStack)[1].FunctionName } $batchCount = [int][Math]::Ceiling(($Requests.count / 20)) foreach ($GraphRequest in $Requests) { Write-Progress -Activity $Activity -Status "Running batch $g of $batchCount" [void]$tmpGraphRequests.Add($GraphRequest) if ($tmpGraphRequests.Count -ge 20) { $g++ $grapRequestBody = ' { "requests": ' + ($tmpGraphRequests | ConvertTo-Json) + ' }' if ($script:GraphEntraAuth) { #TODO: Add support for Graph Batch with EntraAuth $GraphResponses += (Invoke-EntraRequest -Path $BatchPath -Body $grapRequestBody -Method Post -Header $requestHeader).responses } else { $GraphResponses += (Invoke-MgGraphRequest -Method Post -Uri ("/v1.0/" + $BatchPath) -Body $grapRequestBody).responses } $tmpGraphRequests = [System.Collections.ArrayList]::new() } } if ($tmpGraphRequests.Count -gt 0) { Write-Progress -Activity $Activity -Status "Running batch $g of $batchCount" #TO DO: Look for alternatives instead of doing this. if ($tmpGraphRequests.Count -gt 1) { $grapRequestBody = ' { "requests": ' + ($tmpGraphRequests | ConvertTo-Json) + ' }' } else { $grapRequestBody = ' { "requests": [' + ($tmpGraphRequests | ConvertTo-Json) + '] }' } try { if ($script:GraphEntraAuth) { #TODO: Add support for Graph Batch with EntraAuth $GraphResponses += (Invoke-EntraRequest -Path $BatchPath -Body $grapRequestBody -Method Post -Header $requestHeader).responses } else { $GraphResponses += (Invoke-MgGraphRequest -Method Post -Uri ("/v1.0/" + $BatchPath) -Body $grapRequestBody).responses } } catch { Write-Warning "Error while getting the Graph Request." } } #In some cases we will need the complete graph response, in that case the calling function will have to process pending pages. $attempts = 1 for ($j = 0; $j -lt $GraphResponses.length; $j++) { $ResponseCount = 0 if ($IncludeBody) { $outBatchResponses += $GraphResponses[$j] } else { $outBatchResponses += $GraphResponses[$j].body if ($GraphResponses[$j].status -eq "200") { #Checking if there are more pages available $GraphURI_NextPage = $GraphResponses[$j].body.'@odata.nextLink' $GraphTotalCount = $GraphResponses[$j].body.'@odata.count' $ResponseCount += $GraphResponses[$j].body.value.count while (![string]::IsNullOrEmpty($GraphURI_NextPage)) { try { if ($script:GraphEntraAuth) { #TODO: Add support for Graph Batch with EntraAuth, for now we need to use NoPaging to have the same behaviour as Invoke-MgGraphRequest $graphNextPageResponse = Invoke-EntraRequest -Path $GraphURI_NextPage -NoPaging } else { $graphNextPageResponse = Invoke-MgGraphRequest -Method Get -Uri $GraphURI_NextPage } $outBatchResponses += $graphNextPageResponse $GraphURI_NextPage = $graphNextPageResponse.'@odata.nextLink' $ResponseCount += $graphNextPageResponse.value.count Write-Progress -Activity $Activity -Status "$ResponseCount of $GraphTotalCount" } catch { Write-Warning "Failed to get the next batch page, retrying..." $attempts-- } if ($attempts -eq 0) { Write-Warning "Could not get next batch page, skiping it." break } } } else { Write-Warning ("Failed to get Graph Response" + [Environment]::NewLine + ` "Error Code: " + $GraphResponses[$j].status + " " + $GraphResponses[$j].body.error.code + [Environment]::NewLine + ` "Error Message: " + $GraphResponses[$j].body.error.message + [Environment]::NewLine + ` "Request Date: " + $GraphResponses[$j].body.error.innerError.date + [Environment]::NewLine + ` "Request ID: " + $GraphResponses[$j].body.error.innerError.'request-id' + [Environment]::NewLine + ` "Client Request Id: " + $GraphResponses[$j].body.error.innerError.'client-request-id') } } } return $outBatchResponses } } function Test-UcPowerShellModule { <# .SYNOPSIS Test if PowerShell module is installed and updated .DESCRIPTION This function returns FALSE if PowerShell module is not installed. .PARAMETER ModuleName Specifies PowerShell module name .EXAMPLE PS> Test-UcPowerShellModule -ModuleName UCLobbyTeams #> param( [Parameter(Mandatory = $true)] [string]$ModuleName ) try { #Region 2025-07-23: We can use the current module name, this will make the code simpler in the other functions. $ModuleName = $MyInvocation.MyCommand.Module.Name if (!($ModuleName)) { Write-Warning "Please specify a module name using the ModuleName parameter." return } $ModuleNameCheck = Get-Variable -Scope Global -Name ($ModuleName + "ModuleCheck") -ErrorAction SilentlyContinue if ($ModuleNameCheck.Value) { return $true } if ($ModuleNameCheck) { Set-Variable -Scope Global -Name ($ModuleName + "ModuleCheck") -Value $true } else { New-Variable -Scope Global -Name ($ModuleName + "ModuleCheck") -Value $true } #endRegion #Get all installed versions $installedVersions = (Get-Module $ModuleName -ListAvailable | Sort-Object Version -Descending).Version #Get the lastest version available $availableVersion = (Find-Module -Name $ModuleName -Repository PSGallery -ErrorAction SilentlyContinue).Version if (!($installedVersions)) { if ($availableVersion ) { #Module not installed and there is an available version to install. Write-Warning ("The PowerShell Module $ModuleName is not installed, please install the latest available version ($availableVersion) with:" + [Environment]::NewLine + "Install-Module $ModuleName") } else { #Wrong name or not found in the registered PS Repository. Write-Warning ("The PowerShell Module $ModuleName not found in the registered PS Repository, please check the module name and try again.") } return $false } #Get the current loaded version $tmpCurrentVersion = (Get-Module $ModuleName | Sort-Object Version -Descending) if ($tmpCurrentVersion) { $currentVersion = $tmpCurrentVersion[0].Version.ToString() } if (!($currentVersion)) { #Module is installed but not imported, in this case we check if there is a newer version available. if ($availableVersion -in $installedVersions) { Write-Warning ("The lastest available version of $ModuleName module is installed, however the module is not imported." + [Environment]::NewLine + "Please make sure you import it with:" + [Environment]::NewLine + "Import-Module $ModuleName -RequiredVersion $availableVersion") return $false } else { Write-Warning ("There is a new version available $availableVersion, the lastest installed version is " + $installedVersions[0] + "." + [Environment]::NewLine + "Please update the module with:" + [Environment]::NewLine + "Update-Module $ModuleName") } } if ($currentVersion -ne $availableVersion ) { if ($availableVersion -in $installedVersions) { Write-Warning ("The lastest available version of $ModuleName module is installed, however version $currentVersion is imported." + [Environment]::NewLine + "Please make sure you import it with:" + [Environment]::NewLine + "Import-Module $ModuleName -RequiredVersion $availableVersion") } else { Write-Warning ("There is a new version available $availableVersion, current version $currentVersion." + [Environment]::NewLine + "Please update the module with:" + [Environment]::NewLine + "Update-Module $ModuleName") } } return $true } catch { } return $false } function Test-UcServiceConnection { <# .SYNOPSIS Test connection to a Service .DESCRIPTION This function will validate if the there is an active connection to a service and also if the required module is installed. Requirements: MsGraph, TeamsDeviceTAC - EntraAuth PowerShell module (Install-Module EntraAuth) TeamsModule - MicrosoftTeams PowerShell module (Install-Module MicrosoftTeams) .PARAMETER Type Specifies a Type of Service, valid options: MSGraph - Microsoft Graph TeamsModule - Microsoft Teams PowerShell module TeamsDeviceTAC - Teams Admin Center (TAC) API for Teams Devices .PARAMETER Scopes When present it will check if the require permissions are in the current Scope, only applicable to Microsoft Graph API. .PARAMETER AltScopes Allows checking for alternative permissions to the ones specified in AltScopes, only applicable to Microsoft Graph API. .PARAMETER AuthType Some Ms Graph APIs can require specific AuthType, Application or Delegated (User) #> param( [Parameter(mandatory = $true)] [ValidateSet("MSGraph", "TeamsPowerShell", "TeamsDeviceTAC")] [string]$Type, [string[]]$Scopes, [string[]]$AltScopes, [ValidateSet("Application", "Delegated")] [string]$AuthType ) switch ($Type) { "MSGraph" { #UCLobbyTeams is moving to use EntraAuth instead of Microsoft.Graph.Authentication, both will be supported for now. $script:GraphEntraAuth = $false $EntraAuthModuleAvailable = Get-Module EntraAuth -ListAvailable $MSGraphAuthAvailable = Get-Module Microsoft.Graph.Authentication -ListAvailable if ($EntraAuthModuleAvailable) { $AuthToken = Get-EntraToken -Service Graph if ($AuthToken) { $script:GraphEntraAuth = $true $currentScopes = $AuthToken.Scopes $AuthTokenType = $AuthToken.tokendata.idtyp.replace('app', 'Application').replace('user', 'Delegated') } } #EntraAuth has priority if already connected. if ($MSGraphAuthAvailable -and !$script:GraphEntraAuth) { $MgGraphContext = Get-MgContext $currentScopes = $MgGraphContext.Scopes $AuthTokenType = (""+$MgGraphContext.AuthType).replace('AppOnly', 'Application') } if(!$EntraAuthModuleAvailable -and !$MSGraphAuthAvailable) { Write-Warning ("Missing EntraAuth PowerShell module. Please install it with:" + [Environment]::NewLine + "Install-Module EntraAuth") return $false } if (!($currentScopes)) { Write-Warning ("Not Connected to Microsoft Graph" + ` [Environment]::NewLine + "Please connect to Microsoft Graph before running this cmdlet." + ` [Environment]::NewLine + "Commercial Tenant: Connect-EntraService -ClientID Graph -Scopes " + ($Scopes -join ",") + ` [Environment]::NewLine + "US Gov (GCC-H) Tenant: Connect-EntraService -ClientID Graph " + ($Scopes -join ",") + " -Environment USGov") return $false } if ($AuthType -and $AuthTokenType -ne $AuthType) { Write-Warning "Wrong Permission Type: $AuthTokenType, this PowerShell cmdlet requires: $AuthType" return $false } $strScope = "" $strAltScope = "" $missingScopes = "" $missingAltScopes = "" $missingScope = $false $missingAltScope = $false foreach ($scope in $Scopes) { $strScope += "`"" + $scope + "`"," if ($scope -notin $currentScopes) { $missingScope = $true $missingScopes += $scope + "," } } if ($missingScope -and $AltScopes) { foreach ($altScope in $AltScopes) { $strAltScope += "`"" + $altScope + "`"," if ($altScope -notin $currentScopes) { $missingAltScope = $true $missingAltScopes += $altScope + "," } } } else { $missingAltScope = $true } #If scopes are missing we need to connect using the required scopes if ($missingScope -and $missingAltScope) { if ($Scopes -and $AltScopes) { Write-Warning ("Missing scope(s): " + $missingScopes.Substring(0, $missingScopes.Length - 1) + " and missing alternative Scope(s): " + $missingAltScopes.Substring(0, $missingAltScopes.Length - 1) + ` [Environment]::NewLine + "Please reconnect to Microsoft Graph before running this cmdlet." + ` [Environment]::NewLine + "Commercial Tenant: Connect-EntraService -ClientID Graph -Scopes " + $strScope.Substring(0, $strScope.Length - 1) + " or Connect-EntraService -ClientID Graph -Scopes " + $strAltScope.Substring(0, $strAltScope.Length - 1) + ` [Environment]::NewLine + "US Gov (GCC-H) Tenant: Connect-EntraService -ClientID Graph -Environment USGov -Scopes " + $strScope.Substring(0, $strScope.Length - 1) + " or Connect-EntraService -ClientID Graph -Environment USGov -Scopes " + $strAltScope.Substring(0, $strAltScope.Length - 1) ) } else { Write-Warning ("Missing scope(s): " + $missingScopes.Substring(0, $missingScopes.Length - 1) + ` [Environment]::NewLine + "Please reconnect to Microsoft Graph before running this cmdlet." + ` [Environment]::NewLine + "Commercial Tenant: Connect-EntraService -ClientID Graph -Scopes " + $strScope.Substring(0, $strScope.Length - 1) + ` [Environment]::NewLine + "US Gov (GCC-H) Tenant: Connect-EntraService -ClientID Graph -Scopes " + $strScope.Substring(0, $strScope.Length - 1)) } return $false } return $true } "TeamsPowerShell" { #Checking if MicrosoftTeams module is installed if (!(Get-Module MicrosoftTeams -ListAvailable)) { Write-Warning ("Missing MicrosoftTeams PowerShell module. Please install it with:" + [Environment]::NewLine + "Install-Module MicrosoftTeams") return $false } #We need to use a cmdlet to know if we are connected to MicrosoftTeams PowerShell try { Get-CsTenant -ErrorAction SilentlyContinue | Out-Null return $true } catch [System.UnauthorizedAccessException] { Write-Warning ("Please connect to Microsoft Teams PowerShell with Connect-MicrosoftTeams before running this cmdlet") return $false } } "TeamsDeviceTAC" { #Checking if EntraAuth module is installed if (!(Get-Module EntraAuth -ListAvailable)) { Write-Warning ("Missing EntraAuth PowerShell module. Please install it with:" + [Environment]::NewLine + "Install-Module EntraAuth") return $false } if (Get-EntraToken TeamsDeviceTAC) { return $true } else { Write-Warning "Please connect to Teams TAC API with Connect-UcTeamsDeviceTAC before running this cmdlet" } } Default { return $false } } } function Export-UcOneDriveWithMultiplePermissions { <# .SYNOPSIS Generate a report with OneDrive's that have more than a user with access permissions. .DESCRIPTION This script will check all OneDrives and return the OneDrive that have additional users with permissions. Author: David Paulino Requirements: EntraAuth PowerShell Module (Install-Module EntraAuth) or Microsoft Graph Authentication PowerShell Module (Install-Module Microsoft.Graph.Authentication) Microsoft Graph Scopes: "Sites.Read.All" Note: Currently the SharePoint Sites requires to authenticate to Graph API with AppOnly https://learn.microsoft.com/graph/auth/auth-concepts .PARAMETER OutputPath Allows to specify the path where we want to save the results. By default, it will save on current user Download. .PARAMETER MultiGeo Required if Tenant is MultiGeo .EXAMPLE PS> Export-UcOneDriveWithMultiplePermissions .EXAMPLE PS> Export-UcOneDriveWithMultiplePermissions -MultiGeo #> param( [string]$OutputPath, [switch]$MultiGeo ) #region Graph Connection, Scope validation and module version if (!(Test-UcServiceConnection -Type MSGraph -Scopes "Sites.Read.All" -AltScopes ("Sites.ReadWrite.All") -AuthType "Application")) { return } Test-UcPowerShellModule | Out-Null #endregion $startTime = Get-Date #Graph API request is different when the tenant has multigeo if ($MultiGeo) { $outFile = "OneDrivePermissions_MultiGeo_" + (Get-Date).ToString('yyyyMMdd-HHmmss') + ".csv" $GraphPathSites = "/sites/getAllSites?`$select=id,displayName,isPersonalSite,WebUrl&`$top=999" } else { $outFile = "OneDrivePermissions_" + (Get-Date).ToString('yyyyMMdd-HHmmss') + ".csv" $GraphPathSites = "/sites?`$select=id,displayName,isPersonalSite,WebUrl&`$top=999" } #Verify if the Output Path exists if ($OutputPath) { if (!(Test-Path $OutputPath -PathType Container)) { Write-Host ("Error: Invalid folder " + $OutputPath) -ForegroundColor Red return } $OutputFilePath = [System.IO.Path]::Combine($OutputPath, $outFile) } else { $OutputFilePath = [System.IO.Path]::Combine($env:USERPROFILE, "Downloads", $outFile) } $OneDriveProcessed = 0 $OneDriveFound = 0 $BatchNumber = 1 $row = "OneDriveDisplayName,OneDriveUrl,Role,UserWithAccessDisplayName,UserWithAccessUPN,UserWithAccessSharePointLogin,OneDriveID,PermissionID" + [Environment]::NewLine do { try { $ResponseSites = Invoke-UcGraphRequest -Path $GraphPathSites $GraphRequestSites = $ResponseSites.'@odata.nextLink' #Currently the SharePoint API doenst support filter for isPersonalSite, so we need to filter it $tempOneDrives = $ResponseSites.value | Where-Object { $_.isPersonalSite -eq $true } #Adding a progress messsage to show status foreach ($OneDrive in $tempOneDrives) { if ($OneDriveProcessed % 10 -eq 0) { Write-Progress -Activity "Looking for addtional users in OneDrive permissions" -Status "Batch #$BatchNumber - Number of OneDrives Processed $OneDriveProcessed" } $GPOneDrivePermission = "/sites/" + $OneDrive.id + "/drive/root/permissions" try { $OneDrivePermissions = Invoke-UcGraphRequest -Path $GPOneDrivePermission if ($OneDrivePermissions.count -gt 1) { foreach ($OneDrivePermission in $OneDrivePermissions) { if ($OneDrivePermission.grantedToV2.siteuser.displayName -ne $OneDrive.displayName) { $tempUPN = Get-UcUPNFromString $OneDrivePermission.grantedToV2.siteuser.loginName $row += $OneDrive.displayName + "," + $OneDrive.WebUrl + "," + $OneDrivePermission.roles + ",`"" + $OneDrivePermission.grantedToV2.siteuser.displayName + "`"," + $tempUPN + "," + $OneDrivePermission.grantedToV2.siteuser.loginName + ",`"" + $OneDrive.id + "`"," + $OneDrivePermission.id Out-File -FilePath $OutputFilePath -InputObject $row -Encoding UTF8 -append $row = "" $OneDriveFound++ } } } $OneDriveProcessed++ } catch { } } $BatchNumber++ } catch { break } } while (![string]::IsNullOrEmpty($GraphRequestSites)) $endTime = Get-Date $totalSeconds = [math]::round(($endTime - $startTime).TotalSeconds, 2) $totalTime = New-TimeSpan -Seconds $totalSeconds Write-Host "Total of OneDrives processed: $OneDriveProcessed, total OneDrives with additional users with permissions: $OneDriveFound" -ForegroundColor Cyan if ($OneDriveFound -gt 0) { Write-Host ("Results available in " + $OutputFilePath) -ForegroundColor Cyan } Write-Host "Execution time:" $totalTime.Hours "Hours" $totalTime.Minutes "Minutes" $totalTime.Seconds "Seconds" -ForegroundColor Green } function Export-UcSharePointSitesWithEmptyFiles { <# .SYNOPSIS Generate a report with OneDrive's that have more than a user with access permissions. .DESCRIPTION This script will check all SharePoint Sites and OneDrives looking for empty files (size = 0), by default will return PDF, but queries can be used. Author: David Paulino Requirements: EntraAuth PowerShell Module (Install-Module EntraAuth) or Microsoft Graph Authentication PowerShell Module (Install-Module Microsoft.Graph.Authentication) Microsoft Graph Scopes: "Sites.Read.All" Note: Currently the SharePoint Sites requires to authenticate to Graph API with AppOnly https://learn.microsoft.com/graph/auth/auth-concepts .PARAMETER OutputPath Allows to specify the path where we want to save the results. By default, it will save on current user Download. .PARAMETER MultiGeo Required if Tenant is MultiGeo .PARAMETER IncludeOneDrive If OneDrives should also be included in the report. .EXAMPLE PS> Export-UcSharePointSitesWithEmptyFiles .EXAMPLE PS> Export-UcSharePointSitesWithEmptyFiles -MultiGeo .EXAMPLE PS> Export-UcSharePointSitesWithEmptyFiles -IncludeOneDrive #> param( [string]$Query = "PDF", [string]$OutputPath, [switch]$MultiGeo, [switch]$IncludeOneDrive ) #region Graph Connection, Scope validation and module version if (!(Test-UcServiceConnection -Type MSGraph -Scopes "Sites.Read.All" -AltScopes ("Sites.ReadWrite.All") -AuthType "Application")) { return } Test-UcPowerShellModule | Out-Null #endregion $startTime = Get-Date #Graph API request is different when the tenant has multigeo if ($MultiGeo) { $outFile = "SharePointEmptyFiles_MultiGeo_" + (Get-Date).ToString('yyyyMMdd-HHmmss') + ".csv" $GraphRequestSites = "/sites/getAllSites?`$select=id,displayName,isPersonalSite,WebUrl&`$top=999" } else { $outFile = "SharePointEmptyFiles_" + (Get-Date).ToString('yyyyMMdd-HHmmss') + ".csv" $GraphRequestSites = "/sites?`$select=id,displayName,isPersonalSite,WebUrl&`$top=999" } #Verify if the Output Path exists if ($OutputPath) { if (!(Test-Path $OutputPath -PathType Container)) { Write-Host ("Error: Invalid folder " + $OutputPath) -ForegroundColor Red return } $OutputFilePath = [System.IO.Path]::Combine($OutputPath, $outFile) } else { $OutputFilePath = [System.IO.Path]::Combine($env:USERPROFILE, "Downloads", $outFile) } $SharePointSitesProcessed = 0 $EmptyFilesFound = 0 $BatchNumber = 1 $row = "SiteDisplayName,SiteUrl,FileName,Size,createDate,createdByDisplayName,createdByemail,lastModifiedDate,lastModifiedByDisplayName,lastModifiedByEmail" + [Environment]::NewLine do { try { $ResponseSites = Invoke-UcGraphRequest -Path $GraphRequestSites $GraphRequestSites = $ResponseSites.'@odata.nextLink' if ($IncludeOneDrive) { $tempSites = $ResponseSites.value } else { $tempSites = $ResponseSites.value | Where-Object { $_.isPersonalSite -eq $false } } #Adding a progress messsage to show status foreach ($Site in $tempSites) { if ($SharePointSitesProcessed % 10 -eq 0) { Write-Progress -Activity "For empty files" -Status "Batch #$BatchNumber - Number of Sites Processed $SharePointSitesProcessed" } $GRSharePointDrive = "/sites/" + $Site.id + "/drive/root/search(q='$Query')" try { $SPFiles = (Invoke-UcGraphRequest -Path $GRSharePointDrive) if ($SPFiles.value.count -ge 1) { foreach ($SPFile in $SPFiles.value) { if ($SPFile.size -eq 0) { $row += $Site.displayName + "," + $Site.WebUrl + "," + $SPFile.name + "," + $SPFile.size + "," + $SPFile.createdDateTime + "," + $SPFile.createdBy.user.displayName + "," + $SPFile.createdBy.user.email + "," + $SPFile.lastModifiedDateTime + "," + $SPFile.lastModifiedBy.user.displayName + "," + $SPFile.lastModifiedBy.user.email Out-File -FilePath $OutputFilePath -InputObject $row -Encoding UTF8 -append $row = "" $EmptyFilesFound++ } } } $SharePointSitesProcessed++ } catch { } } $BatchNumber++ } catch { break } } while (![string]::IsNullOrEmpty($GraphRequestSites)) $endTime = Get-Date $totalSeconds = [math]::round(($endTime - $startTime).TotalSeconds, 2) $totalTime = New-TimeSpan -Seconds $totalSeconds Write-Host "Total of Sites processed: $SharePointSitesProcessed, total empty files: $EmptyFilesFound" -ForegroundColor Cyan if ($EmptyFilesFound -gt 0) { Write-Host ("Results available in " + $OutputFilePath) -ForegroundColor Cyan } Write-Host "Execution time:" $totalTime.Hours "Hours" $totalTime.Minutes "Minutes" $totalTime.Seconds "Seconds" -ForegroundColor Green } function Export-UcSharePointSitesWithHold { <# .SYNOPSIS Report with SharePoint Sites/OneDrive with a hold in place. .DESCRIPTION This script will generate a csv file with Sites/OneDrives with a hold in place. Author: David Paulino Requirements: SharePoint Online PowerShell Install-Module -Name Microsoft.Online.SharePoint.PowerShell Connect-SPOService -Url https://contoso-admin.sharepoint.com Security & Compliance PowerShell Install-Module -Name ExchangeOnlineManagement Connect-IPPSSession -UserPrincipalName user.adm@contoso.onmicrosoft.com .PARAMETER OutputPath Allows to specify the path where we want to save the results. By default, it will save on current user Download. .PARAMETER IncludeOneDrive If OneDrives should also be included in the report. .EXAMPLE PS> Export-UcSharePointSitesWithHold .EXAMPLE PS> Export-UcSharePointSitesWithHold -IncludeOneDrive #> param( [string]$OutputPath, [switch]$IncludeOneDrive ) $startTime = Get-Date #TODO: SharePoint and Security and Compliance Connectivity check. #2025-07-23: All logic to check if we run this and getting the module name moved to the Test-UcPowerShellModule. Test-UcPowerShellModule | Out-Null #endregion $outFile = "SharePointSitesWithHolds_" + (Get-Date).ToString('yyyyMMdd-HHmmss') + ".csv" #Verify if the Output Path exists if ($OutputPath) { if (!(Test-Path $OutputPath -PathType Container)) { Write-Host ("Error: Invalid folder " + $OutputPath) -ForegroundColor Red return } $OutputFilePath = [System.IO.Path]::Combine($OutputPath, $outFile) } else { $OutputFilePath = [System.IO.Path]::Combine($env:USERPROFILE, "Downloads", $outFile) } if ($IncludeOneDrive) { Write-Warning "SharePoint Sites and OneDrives" $activityMsg = "Checking SharePoint Sites and OneDrives with Holds" $SPSites = Get-SPOSite -Limit all -IncludePersonalSite:$true } else { Write-Warning "Getting the SharePoint Sites" $activityMsg = "Checking SharePoint Sites with Holds" $SPSites = Get-SPOSite -Limit all } $SitesProcessed = 1 $SitesTotal = $SPSites.count $row = "SiteURL,HoldID,HoldCreatedDate,HoldCreatedBy,HoldEnabled,Type,IsAdaptivePolicy" + [Environment]::NewLine foreach ($SPSite in $SPSites) { try { $SiteHolds = Invoke-HoldRemovalAction -Action GetHolds -SharePointLocation $SPSite.Url -ErrorAction SilentlyContinue Write-Progress -Activity $activityMsg -Status ("Processing site " + $SPSite.Url + " - " + $SitesProcessed + " of " + $SitesTotal) foreach ($SiteHold in $SiteHolds) { $SiteCompliancePolicy = Get-RetentionCompliancePolicy -Identity $SiteHold $row += $SPSite.Url + "," + $SiteHold + "," + $SiteCompliancePolicy.WhenCreatedUTC + "," + $SiteCompliancePolicy.CreatedBy + "," + $SiteCompliancePolicy.Enabled + "," + $SiteCompliancePolicy.Type + "," + $SiteCompliancePolicy.IsAdaptivePolicy Out-File -FilePath $OutputFilePath -InputObject $row -Encoding UTF8 -append $row = "" } $SitesProcessed++ } catch { write-warning ("Failed to get Holds for site: " + $SPSite.Url) } } $endTime = Get-Date $totalSeconds = [math]::round(($endTime - $startTime).TotalSeconds, 2) $totalTime = New-TimeSpan -Seconds $totalSeconds Write-Host ("Results available in " + $OutputFilePath) -ForegroundColor Cyan Write-Host "Execution time:" $totalTime.Hours "Hours" $totalTime.Minutes "Minutes" $totalTime.Seconds "Seconds" -ForegroundColor Green } |