Inventory/Get-OneDriveInventory.ps1

<#
.SYNOPSIS
    Generates a per-user OneDrive inventory with storage usage and activity.
.DESCRIPTION
    Retrieves OneDrive data using the direct Graph Users/Drive API (preferred) for
    real user names and site URLs, falling back to the Reports API if the required
    permissions are not available.
 
    Primary path (User.Read.All): Enumerates enabled users and retrieves each user's
    OneDrive drive for storage quota. Returns real display names and UPNs regardless
    of tenant privacy settings.
 
    Fallback path (Reports.Read.All): Uses the Reports API usage report. Note that
    the Reports API anonymizes user-identifiable information by default. To see real
    user names and UPNs with this path, a tenant admin must disable the privacy
    setting at: Microsoft 365 Admin Center > Settings > Org Settings > Reports >
    "Display concealed user, group, and site names in all reports".
 
    Requires Microsoft.Graph.Authentication module and an active Graph connection.
    Preferred permission: User.Read.All. Fallback permission: Reports.Read.All.
.PARAMETER OutputPath
    Optional path to export results as CSV. If not specified, results are returned
    to the pipeline.
.EXAMPLE
    PS> . .\Common\Connect-Service.ps1
    PS> Connect-Service -Service Graph -Scopes 'User.Read.All'
    PS> .\Inventory\Get-OneDriveInventory.ps1
 
    Returns per-user OneDrive inventory using the direct Users/Drive API.
.EXAMPLE
    PS> .\Inventory\Get-OneDriveInventory.ps1 -OutputPath '.\onedrive-inventory.csv'
 
    Exports the OneDrive inventory to CSV.
.NOTES
    M365 Assess — M&A Inventory
#>

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$OutputPath
)

$ErrorActionPreference = 'Stop'

# Verify Graph connection
if (-not (Assert-GraphConnection)) { return }

# ------------------------------------------------------------------
# Phase 1: Try direct Graph Users/Drive API (returns real data)
# ------------------------------------------------------------------
$usedDirectApi = $false
$results = $null

try {
    Write-Verbose "Attempting direct Users/Drive API for OneDrive inventory..."

    $allUsers = [System.Collections.Generic.List[object]]::new()
    $uri = "/v1.0/users?`$filter=accountEnabled eq true&`$select=id,displayName,userPrincipalName&`$top=999"

    do {
        $response = Invoke-MgGraphRequest -Method GET -Uri $uri
        if ($response.value) {
            foreach ($user in $response.value) {
                $allUsers.Add($user)
            }
        }
        $uri = $response.'@odata.nextLink'
    } while ($uri)

    if ($allUsers.Count -eq 0) {
        Write-Verbose "No enabled users found"
        return
    }

    Write-Verbose "Found $($allUsers.Count) enabled users. Checking OneDrive provisioning..."

    $resultList = [System.Collections.Generic.List[PSCustomObject]]::new()
    $counter = 0

    foreach ($user in $allUsers) {
        $counter++
        if ($counter % 25 -eq 0 -or $counter -eq 1) {
            Write-Verbose "[$counter/$($allUsers.Count)] $($user.userPrincipalName)"
        }

        try {
            $driveInfo = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/users/$($user.id)/drive?`$select=quota,webUrl,lastModifiedDateTime"

            $storageUsedMB = $null
            $storageAllocatedMB = $null
            if ($driveInfo.quota) {
                if ($null -ne $driveInfo.quota.used) {
                    $storageUsedMB = [math]::Round([long]$driveInfo.quota.used / 1MB, 2)
                }
                if ($null -ne $driveInfo.quota.total) {
                    $storageAllocatedMB = [math]::Round([long]$driveInfo.quota.total / 1MB, 2)
                }
            }

            $resultList.Add([PSCustomObject]@{
                OwnerDisplayName   = $user.displayName
                OwnerPrincipalName = $user.userPrincipalName
                SiteUrl            = $driveInfo.webUrl
                IsDeleted          = $false
                StorageUsedMB      = $storageUsedMB
                StorageAllocatedMB = $storageAllocatedMB
                FileCount          = $null
                ActiveFileCount    = $null
                LastActivityDate   = $driveInfo.lastModifiedDateTime
            })
        }
        catch {
            # 404 = user has no OneDrive provisioned — expected, skip silently
            Write-Verbose "No OneDrive for $($user.userPrincipalName): $($_.Exception.Message)"
        }
    }

    $results = @($resultList) | Sort-Object -Property OwnerDisplayName
    $usedDirectApi = $true
    Write-Verbose "Direct API inventory complete: $($resultList.Count) OneDrive accounts"
}
catch {
    $directApiError = $_

    if ($directApiError.Exception.Message -match '401|403|Unauthorized|Forbidden|insufficient') {
        Write-Warning ("Direct Users/Drive API unavailable (missing User.Read.All permission). " +
            "Falling back to Reports API. Data may be obfuscated if tenant privacy settings are enabled.")
    }
    else {
        Write-Warning "Direct Users/Drive API failed: $($directApiError.Exception.Message). Falling back to Reports API."
    }
}

# ------------------------------------------------------------------
# Phase 2: Fallback to Reports API if direct API was not used
# ------------------------------------------------------------------
if (-not $usedDirectApi) {
    $reportUri = "/v1.0/reports/getOneDriveUsageAccountDetail(period='D7')"
    Write-Verbose "Downloading OneDrive usage report from Graph Reports API..."

    $tempFile = [System.IO.Path]::GetTempFileName()
    try {
        Invoke-MgGraphRequest -Method GET -Uri $reportUri -OutputFilePath $tempFile
        $reportData = @(Import-Csv -Path $tempFile)
    }
    catch {
        Write-Error "Failed to retrieve OneDrive usage report: $_"
        return
    }
    finally {
        Remove-Item -Path $tempFile -Force -ErrorAction SilentlyContinue
    }

    if ($reportData.Count -eq 0) {
        Write-Verbose "No OneDrive accounts found in the usage report"
        return
    }

    # Phase 3: Detect obfuscated data
    $sampleOwner = $reportData[0].'Owner Display Name'
    $sampleUpn = $reportData[0].'Owner Principal Name'
    $hexPattern = '^[0-9A-Fa-f]{16,}$'

    if (($sampleOwner -match $hexPattern) -or ($sampleUpn -match $hexPattern)) {
        Write-Warning ("Reports API data appears obfuscated (tenant privacy settings are enabled). " +
            "Owner names and UPNs are anonymized. To see real data, " +
            "a tenant admin must disable the privacy setting at: " +
            "Microsoft 365 Admin Center > Settings > Org Settings > Reports > " +
            "'Display concealed user, group, and site names in all reports'. " +
            "Alternatively, grant User.Read.All permission for the direct API path.")
    }

    Write-Verbose "Processing $($reportData.Count) OneDrive accounts from Reports API..."

    $results = foreach ($row in $reportData) {
        $storageUsedMB = $null
        if ($row.'Storage Used (Byte)') {
            $storageUsedMB = [math]::Round([long]$row.'Storage Used (Byte)' / 1MB, 2)
        }

        $storageAllocatedMB = $null
        if ($row.'Storage Allocated (Byte)') {
            $storageAllocatedMB = [math]::Round([long]$row.'Storage Allocated (Byte)' / 1MB, 2)
        }

        [PSCustomObject]@{
            OwnerDisplayName   = $row.'Owner Display Name'
            OwnerPrincipalName = $row.'Owner Principal Name'
            SiteUrl            = $row.'Site URL'
            IsDeleted          = $row.'Is Deleted'
            StorageUsedMB      = $storageUsedMB
            StorageAllocatedMB = $storageAllocatedMB
            FileCount          = $row.'File Count'
            ActiveFileCount    = $row.'Active File Count'
            LastActivityDate   = $row.'Last Activity Date'
        }
    }

    $results = @($results) | Sort-Object -Property OwnerDisplayName
    Write-Verbose "Reports API inventory complete: $($results.Count) OneDrive accounts"
}

# ------------------------------------------------------------------
# Output
# ------------------------------------------------------------------
if ($OutputPath) {
    $results | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    Write-Output "Exported OneDrive inventory ($($results.Count) accounts) to $OutputPath"
}
else {
    Write-Output $results
}