#Requires -Version 5.1 <# .SYNOPSIS Runs a one-shot, customer-local Power BI workspace metadata scan. .DESCRIPTION Uses Microsoft's first-party Power BI PowerShell user sign-in. It makes only GET requests to the Power BI REST API and writes one JSON file on this computer. It does not call any web application or send data to a consultant. .EXAMPLE ./Scan-PowerBI.ps1 -ListWorkspaces .EXAMPLE ./Scan-PowerBI.ps1 -WorkspaceId '00000000-0000-0000-0000-000000000001' -OutputPath './powerbi-scan.json' #> [CmdletBinding(DefaultParameterSetName = 'Scan')] param( [Parameter(Mandatory = $true, ParameterSetName = 'List')] [switch] $ListWorkspaces, [Parameter(Mandatory = $true, ParameterSetName = 'Scan')] [string[]] $WorkspaceId, [Parameter(Mandatory = $true, ParameterSetName = 'Scan')] [string] $OutputPath, [Parameter(ParameterSetName = 'Scan')] [string] $TenantName = 'Connected Power BI workspaces', [Parameter(ParameterSetName = 'Scan')] [switch] $ExcludeWorkspaceUsers, [Parameter(ParameterSetName = 'Scan')] [switch] $Force ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $PageSize = 100 $MaxPages = 200 $MaxAttempts = 3 $MaxReports = 100 $MaxModels = 100 $MaxRefreshes = 6000 $MaxUsers = 5000 $MaxSnapshotBytes = 8 * 1024 * 1024 function Get-PropertyValue { param([object] $InputObject, [string] $Name) if ($null -eq $InputObject) { return $null } $property = $InputObject.PSObject.Properties[$Name] if ($null -eq $property) { return $null } return $property.Value } function Get-RequiredString { param([object] $InputObject, [string] $Name, [int] $MaximumLength = 500) $value = Get-PropertyValue $InputObject $Name if (($value -isnot [string]) -or [string]::IsNullOrWhiteSpace($value) -or $value.Trim().Length -gt $MaximumLength) { throw 'INVALID_RESPONSE' } return $value.Trim() } function Get-OptionalString { param([object] $InputObject, [string] $Name, [int] $MaximumLength = 500) $value = Get-PropertyValue $InputObject $Name if ($null -eq $value -or $value -eq '') { return $null } if (($value -isnot [string]) -or $value.Trim().Length -gt $MaximumLength) { throw 'INVALID_RESPONSE' } return $value.Trim() } function Get-EntityId { param([object] $InputObject, [string] $Name) # Power BI IDs are normally GUIDs. Canonicalizing GUIDs avoids false # cross-reference failures when different endpoints vary letter case. $value = Get-RequiredString $InputObject $Name 120 $parsed = [guid]::Empty if ([guid]::TryParse($value, [ref] $parsed)) { return $parsed.ToString() } return $value.ToLowerInvariant() } function Get-RequiredDate { param([object] $InputObject, [string] $Name) $value = Get-PropertyValue $InputObject $Name try { if ($value -is [DateTimeOffset]) { return $value } if ($value -is [DateTime]) { if ($value.Kind -eq [DateTimeKind]::Unspecified) { throw 'INVALID_RESPONSE' } return [DateTimeOffset] $value } if ($value -is [string] -and $value.Length -le 80 -and $value -match '(?:[zZ]|[+-]\d{2}:\d{2})$') { return [DateTimeOffset]::Parse($value, [Globalization.CultureInfo]::InvariantCulture) } } catch { } throw 'INVALID_RESPONSE' } function Assert-UniqueIds { param([object[]] $Items) $seen = @{} foreach ($item in $Items) { if ($seen.ContainsKey($item.id)) { throw 'INVALID_RESPONSE' } $seen[$item.id] = $true } } function Get-HttpStatus { param([System.Management.Automation.ErrorRecord] $Failure) $exception = $Failure.Exception for ($depth = 0; ($null -ne $exception) -and ($depth -lt 5); $depth++) { $response = Get-PropertyValue $exception 'Response' $status = Get-PropertyValue $response 'StatusCode' if ($null -ne $status) { try { return [int] $status } catch { } } $exception = $exception.InnerException } # Some Power BI module versions include the HTTP status only in the # exception text. Read it for classification, never copy the text to output. $message = [string] $Failure.Exception.Message $match = [regex]::Match($message, '(?