functions/accessReviews/Export-TmfAccessReview.ps1
|
<#
.SYNOPSIS Exports access review definitions. .DESCRIPTION Retrieves access review schedule definitions from Microsoft Graph (v1.0 by default; beta with -ForceBeta) and converts them to the TMF shape. Returns objects unless -OutPath is supplied (writes to accessReviews/accessReviews.json). Legacy alias -OutPutPath is deprecated. .PARAMETER SpecificResources Optional list of access review IDs or display names (comma separated accepted) to filter. .PARAMETER groups Optional list of group IDs, displaynames or wildcards, e.g. "MyGroupNames*" (finds all groups, whose displayname contains "MyGroupNames" and uses them to gather accessReviews on these groups) .PARAMETER OutPath Root folder to write the export. When omitted, objects are returned. Legacy alias -OutPutPath is accepted (deprecated). .PARAMETER ForceBeta Use beta Graph endpoint for retrieval (may expose additional properties). .PARAMETER Cmdlet Internal pipeline parameter; do not supply manually. .EXAMPLE Export-TmfAccessReview -OutPath C:\temp\tmf .EXAMPLE Export-TmfAccessReview -SpecificResources "Review 1","abcd-1234" | ConvertTo-Json -Depth 15 .EXAMPLE Export-TmfAccessReview -SpecificResources "ca314f7f-9bec-4914-ab25-c1fc936c739b","Review 2" -groups "ca219f7f-9bec-4914-ab25-c1fc936c739f","MyDistinctGroupName", "MyGroupNamePrefix*" -OutPath C:\temp\tmf #> function Export-TmfAccessReview { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars")] [CmdletBinding()] param( [string[]] $SpecificResources, [string[]] $groups, [Alias('OutPutPath')] [string] $OutPath, [switch] $ForceBeta, [switch] $Append, [int] $PageSize = 999, # user-requested, internally capped to 100 by API [switch] $SkipScopeNameLookup, [int] $BatchResolveSize = 50, [int] $MaxRetry = 5, [int] $InitialRetrySeconds = 2, [int] $MaxDefinitions, # optional: stop after N processed (useful for sampling / test runs) [int] $TimeoutMinutes, # optional: stop after elapsed minutes [switch] $JsonLines, # write one JSON object per line (no surrounding array) for safer partial/interrupted runs [System.Management.Automation.PSCmdlet] $Cmdlet = $PSCmdlet ) begin { Test-GraphConnection -Cmdlet $Cmdlet $resourceName = 'accessReviews' $graphBase = if ($ForceBeta) { $script:graphBaseUrl } else { $script:graphBaseUrl1 } $tenantMeta = (Invoke-MgGraphRequest -Method GET -Uri ("$($script:graphBaseUrl)/organization?`$select=displayName,id")).value $accessReviewsExport = @() $streaming = $false $jsonFilePath = $null $streamWriter = $null $firstWrite = $true $global:bufferedFirstWrite = $true $totalExpected = $null $lastPercent = -1 $terminatedEarly = $false $terminationReason = $null $startTime = Get-Date # Caches & batching structures $groupNameCache = @{} $roleNameCache = @{} $groupPending = [System.Collections.Generic.HashSet[string]]::new() $rolePending = [System.Collections.Generic.HashSet[string]]::new() $buffer = New-Object System.Collections.ArrayList # Prepare SpecificResources filters $filterIDs = @() $filterNames = @() if ($SpecificResources) { $SpecificResources | ForEach-Object { $_ -split ',' } | ForEach-Object Trim | Where-Object { $_ } | Select-Object -Unique | ForEach-Object { if ($_ -match $script:guidRegex) { $filterIDs += $_ } else { $filterNames += $_ } } } if ($groups) { $groupIDs = @() $groupNames = @() $groupSearches = @() foreach ($group in $groups) { if ($group -match $script:guidRegex) { $groupIDs += $group } elseif ($group -match "\*") { $groupSearches += $group.replace("*","") } else { $groupNames += $group } } } if ($OutPath) { if (-not (Test-Path -LiteralPath (Join-Path $OutPath $resourceName))) { New-Item -Path $OutPath -Name $resourceName -ItemType Directory -Force | Out-Null } $jsonFilePath = (Join-Path (Join-Path $OutPath $resourceName) "$resourceName.json") # Create/overwrite file and write opening bracket for JSON array if ((-not $SpecificResources) -and (-not $groups)) { $streamWriter = [System.IO.StreamWriter]::new($jsonFilePath, $false, [System.Text.UTF8Encoding]::new($false)) if (-not $JsonLines) { $streamWriter.Write('[') } $streaming = $true } } function Convert-ReviewerList { param([object[]]$List) if (-not $List) { return @() } $out = @() foreach ($rev in $List) { if (-not $rev) { continue } $principalId = $rev.principalId if (-not $principalId -and $rev.query -match '/groups/([0-9a-fA-F\-]{36})') { $principalId = $rev.query.split("/")[3] Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Resolving group $principalId" $reference = Resolve-Group -InputReference $principalId -DisplayName -DontFailIfNotExisting -Cmdlet $Cmdlet if ($reference -eq $principalId) { $reference = "NotFound" } } if (-not $principalId -and $rev.query -match '/users/([0-9a-fA-F\-]{36})') { $principalId = $rev.query.split("/")[3] Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Resolving user $principalId" $reference = Resolve-User -InputReference $principalId -Userprincipalname -DontFailIfNotExisting -Cmdlet $Cmdlet if ($reference -eq $principalId) { $reference = "NotFound" } } if (-not $principalId) { continue } $type = if ($rev.query -and $rev.query -match '/transitiveMembers') { 'groupMembers' } elseif ($rev.query -and $rev.query -match '/owners') { 'owners' } else { 'singleUser' } $out += [pscustomobject]@{ type = $type; reference = $reference } } return $out } function Resolve-ScopeDisplayName { param([object]$Scope) if (-not $Scope) { return $null } $simple = [ordered]@{} $query = $Scope.query $roleId = $null; $groupId = $null if ($query -match "/groups/([0-9a-fA-F\-]{36})") { $groupId = $matches[1] } if ($query -match "uniqueRoleId='([0-9a-fA-F\-]{36})'") { $roleId = $matches[1] } if ($groupId) { if ($Scope.query -match "userType eq 'Guest'") { $simple.type = 'groupGuests' } else { $simple.type = 'group' } if ($SkipScopeNameLookup) { $simple.reference = $groupId } elseif ($groupNameCache.ContainsKey($groupId)) { $simple.reference = $groupNameCache[$groupId] } else { $simple.reference = Resolve-Group -InputReference $groupId -DisplayName -DontFailIfNotExisting if ($simple.reference -eq $groupId -or (-not $simple.reference)) { $simple.reference = "NotFound" } } # fallback if not yet resolved } elseif ($roleId) { $simple.type = 'directoryRole' if ($SkipScopeNameLookup) { $simple.reference = $roleId } elseif ($roleNameCache.ContainsKey($roleId)) { $simple.reference = $roleNameCache[$roleId] } else { try { $simple.reference = Resolve-DirectoryRoleTemplate -InputReference $roleId -DisplayName -DontFailIfNotExisting if ($simple.reference -eq $roleId -or (-not $simple.reference)) { $simple.reference = "NotFound" } } catch { $simple.reference = "NotFound" } } $simple.subScope = if ($query -match 'servicePrincipal') { 'servicePrincipals' } else { 'users_groups' } } else { if ($Scope.type) { $simple.type = $Scope.type } if ($Scope.reference) { $simple.reference = $Scope.reference } } return $simple } function Convert-AccessReview { param([object]$Review) $obj = [ordered]@{ displayName = $Review.displayName; present = $true } # include all @odata.* properties foreach ($p in $Review.PSObject.Properties) { if ($p.Name -like '@odata*') { $obj[$p.Name] = $p.Value } } if ($Review.description) { $obj.description = $Review.description } if ($Review.scope) { switch ($Review.scope."@odata.type") { "#microsoft.graph.accessReviewQueryScope" { if ($Review.scope.query -match "/groups" -or $Review.scope.query -match "uniqueRoleId") { $obj.scope = (Resolve-ScopeDisplayName -Scope $Review.scope) } else { $obj.scope = $Review.scope } } "#microsoft.graph.principalResourceMembershipsScope" { $obj.scope = $Review.scope } } } if ($Review.reviewers) { $obj.reviewers = (Convert-ReviewerList -List $Review.reviewers) } if ($Review.fallbackReviewers) { $obj.fallbackReviewers = (Convert-ReviewerList -List $Review.fallbackReviewers) } if ($Review.settings) { $obj.settings = $Review.settings } if ($obj.scope.reference -match $script:guidRegex -or $obj.scope.reference -eq "NotFound" -or (-not $obj.scope) -or $obj.reviewers.reference -contains "NotFound" -or (-not $obj.reviewers)) { Write-PSFMessage -Level Verbose -Message "Access review $($obj.displayname) skipped due to unresolvable scope or reviewers." } else { [pscustomobject]$obj } } function Invoke-GraphWithRetry { param([string]$Uri, [hashtable]$Headers) $attempt = 0; $delay = $InitialRetrySeconds while ($true) { try { return Invoke-MgGraphRequest -Method GET -Uri $Uri -Headers $Headers } catch { $attempt++ $status = $_.Exception.Response.StatusCode.value__ 2>$null $isThrottle = ($status -eq 429 -or $status -eq 503 -or $status -eq 504) if (-not $isThrottle -or $attempt -ge $MaxRetry) { throw } Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Throttle/Retry attempt $attempt for $Uri (status $status). Sleeping $delay s."; Start-Sleep -Seconds $delay; $delay = [Math]::Min($delay * 2, 120) } } } # Legacy alias placeholder (removed hyphenated name to satisfy analyzer) function GetAccessReviewPageData { $requested = if ($PageSize -gt 0) { $PageSize } else { 100 } if ($requested -gt 100) { Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Requested PageSize $requested exceeds API max 100; capping."; $requested = 100 } $topParam = "$requested" $baseParams = "`$top=$topParam" $countUri = "$graphBase/identityGovernance/accessReviews/definitions?$baseParams&`$count=true" $headers = @{ } try { $headers['ConsistencyLevel'] = 'eventual' $firstResp = Invoke-GraphWithRetry -Uri $countUri -Headers $headers if ($firstResp.'@odata.count') { $global:__accessReviewTotal = $firstResp.'@odata.count'; $script:__accessReviewFirstPage = $firstResp } } catch { $headers.Remove('ConsistencyLevel') 2>$null } if (-not $script:__accessReviewFirstPage) { $script:__accessReviewFirstPage = Invoke-GraphWithRetry -Uri "$graphBase/identityGovernance/accessReviews/definitions?$baseParams" -Headers $headers } $totalExpected = $global:__accessReviewTotal $page = 0; $processed = 0 $resp = $script:__accessReviewFirstPage while ($true) { $page++ foreach ($item in @($resp.value)) { # Early termination checks (timeout / max definitions) if ($TimeoutMinutes -and ((Get-Date) - $startTime).TotalMinutes -ge $TimeoutMinutes) { $terminatedEarly = $true; $terminationReason = "Timeout ($TimeoutMinutes minute limit reached)"; break } # Filter resources if specified if ($filterIdSet.Count -gt 0 -or $filterNameSet.Count -gt 0) { $match = $false if ($filterIdSet.Count -gt 0 -and $item.id -and $filterIdSet.Contains($item.id)) { $match = $true } elseif ($filterNameSet.Count -gt 0 -and $item.displayName -and $filterNameSet.Contains($item.displayName)) { $match = $true } if (-not $match) { continue } } if ($MaxDefinitions -and $processed -ge $MaxDefinitions) { $terminatedEarly = $true; $terminationReason = "MaxDefinitions ($MaxDefinitions) reached"; break } $processed++ # Extract scope IDs for later batch resolution if (-not $SkipScopeNameLookup -and $item.scope -and $item.scope.query) { $q = $item.scope.query if ($q -match "/groups/([0-9a-fA-F\-]{36})" ) { $gid = $matches[1]; if (-not $groupNameCache.ContainsKey($gid)) { [void]$groupPending.Add($gid) } } if ($q -match "uniqueRoleId='([0-9a-fA-F\-]{36})'") { $rid = $matches[1]; if (-not $roleNameCache.ContainsKey($rid)) { [void]$rolePending.Add($rid) } } } # Buffer review [void]$buffer.Add($item) if ($buffer.Count -ge $BatchResolveSize) { Write-BufferedAccessReviews } if ($totalExpected) { $pct = [int](($processed / $totalExpected) * 100) if ($pct -ne $lastPercent -and ($pct % 5) -eq 0) { Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Progress: $processed / $totalExpected ($pct%)"; $lastPercent = $pct } } elseif (($processed % 5000) -eq 0) { Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Processed $processed definitions..." } } if ($terminatedEarly) { break } $next = $resp.'@odata.nextLink' if (-not $next) { break } $resp = Invoke-GraphWithRetry -Uri $next -Headers $headers } # flush remainder Write-BufferedAccessReviews } function Write-BufferedAccessReviews { if ($buffer.Count -eq 0) { return } Write-PSFMessage -Level Verbose -Message "BufferedFirstWrite: $global:bufferedFirstWrite" # Lazy create writer if streaming somehow enabled but writer not initialized (safety net) if ($streaming -and -not $streamWriter) { if (-not (Test-Path -LiteralPath (Join-Path $OutPath $resourceName))) { New-Item -Path $OutPath -Name $resourceName -ItemType Directory -Force | Out-Null } $jsonFilePath = (Join-Path (Join-Path $OutPath $resourceName) "$resourceName.json") $streamWriter = [System.IO.StreamWriter]::new($jsonFilePath, $false, [System.Text.UTF8Encoding]::new($false)) if ($firstWrite -and -not $JsonLines) { $streamWriter.Write('[') } # ensure opening bracket if not written } if ($global:bufferedFirstWrite) { $global:bufferedFirstWrite = $false } else { $streamWriter.Write(",`n") } if (-not $SkipScopeNameLookup) { if ($groupPending.Count -gt 0) { $groupIds = @($groupPending) try { $resolvedGroups = Resolve-Group -InputReference $groupIds -DontFailIfNotExisting -DisplayName -Cmdlet $Cmdlet for ($i = 0; $i -lt $groupIds.Count; $i++) { $gid = $groupIds[$i]; $dn = $resolvedGroups[$i]; if ($dn -and $dn -ne $gid) { $groupNameCache[$gid] = $dn } else { $groupNameCache[$gid] = $gid } } } catch { Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Group batch resolve warning: $($_.Exception.Message)" } $groupPending.Clear() } if ($rolePending.Count -gt 0) { $roleIds = @($rolePending) try { $resolvedRoles = Resolve-DirectoryRoleTemplate -InputReference $roleIds -DontFailIfNotExisting -DisplayName -Cmdlet $Cmdlet for ($i = 0; $i -lt $roleIds.Count; $i++) { $rid = $roleIds[$i]; $dn = $resolvedRoles[$i]; if ($dn -and $dn -ne $rid) { $roleNameCache[$rid] = $dn } else { $roleNameCache[$rid] = $rid } } } catch { Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Role batch resolve warning: $($_.Exception.Message)" } $rolePending.Clear() } } Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Firstwrite: $firstWrite" foreach ($rev in @($buffer)) { $converted = Convert-AccessReview $rev if ($streaming -and $converted) { $json = $converted | ConvertTo-Json -Depth 15 if ($JsonLines) { if (-not $firstWrite) { $streamWriter.Write("`n") } $streamWriter.Write($json) } else { if (-not $firstWrite) { $streamWriter.Write(",`n") } $streamWriter.Write($json) } $firstWrite = $false $streamWriter.Flush() # ensure on-disk frequently to reduce loss risk on interruption } else { if ($converted) { $accessReviewsExport += $converted } } } $streamWriter.Flush() $buffer.Clear() } function GetFilteredAccessReviews { param ( [string[]]$filterIDs, [string[]]$filterNames, [string[]]$groupIDs, [string[]]$groupNames, [string[]]$groupSearches ) $accessReviewsExport = @() $filteredReviewResults = @() if ($filterIDs) { foreach ($id in $filterIDs) { $filteredReviewResults += Invoke-MgGraphRequest -Method GET -Uri "$graphBase/identityGovernance/accessReviews/definitions/$($id)" } } if ($filterNames) { foreach ($name in $filterNames) { $filteredReviewResults += (Invoke-MgGraphRequest -Method GET -Uri "$graphBase/identityGovernance/accessReviews/definitions?`$filter=displayname eq '$($name)'").value } } if ($groupIDs) { foreach ($groupId in $groupIDs) { $filteredReviewResults += (Invoke-MgGraphRequest -Method GET -Uri "$graphBase/identityGovernance/accessReviews/definitions?`$filter=contains(scope/microsoft.graph.accessReviewQueryScope/query, '/groups/$($groupId)')").value } } if ($groupNames) { $resolvedGroupIDs = Resolve-Group -InputReference $groupNames -DontFailIfNotExisting -SearchInDesiredConfiguration foreach ($groupId in $resolvedGroupIDs) { $filteredReviewResults += (Invoke-MgGraphRequest -Method GET -Uri "$graphBase/identityGovernance/accessReviews/definitions?`$filter=contains(scope/microsoft.graph.accessReviewQueryScope/query, '/groups/$($groupId)')").value } } if ($groupSearches) { $resolvedGroupSearchIDs = foreach ($search in $groupSearches) {(Invoke-MgGraphRequest -Method GET -Uri "$graphBase/groups?`$search=`"displayName:$($search)`"&`$top=999&`$select=id" -Headers @{"ConsistencyLevel"="eventual"}).value.id} foreach ($groupId in $resolvedGroupSearchIDs) { $filteredReviewResults += (Invoke-MgGraphRequest -Method GET -Uri "$graphBase/identityGovernance/accessReviews/definitions?`$filter=contains(scope/microsoft.graph.accessReviewQueryScope/query, '/groups/$($groupId)')").value } } foreach ($review in $filteredReviewResults) { $accessReviewsExport += Convert-AccessReview -Review $review } return $accessReviewsExport } } process { if ($SpecificResources -or $Groups) { $accessReviewsExport = GetFilteredAccessReviews -filterIDs $filterIDs -filterNames $filterNames -groupIDs $groupIDs -groupNames $groupNames -groupSearches $groupSearches } else { if ($Append -and $OutPath) { Write-PSFMessage -Level Warning -Message "Switch -Append is only supported for filtered exports (SpecificResources,Groups). File $OutPath will be overwritten!" } GetAccessReviewPageData } } end { if ($streaming) { if (-not $JsonLines) { $streamWriter.Write(']') } $streamWriter.Flush(); $streamWriter.Dispose() if ($terminatedEarly) { Write-PSFMessage -Level Warning -FunctionName 'Export-TmfAccessReview' -Message "Export terminated early: $terminationReason. File: $jsonFilePath (partial output) Tenant: $($tenantMeta.displayName)" } else { Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Completed streaming export to $jsonFilePath for tenant $($tenantMeta.displayName)" } } else { Write-PSFMessage -Level Verbose -FunctionName 'Export-TmfAccessReview' -Message "Exporting $($accessReviewsExport.Count) access review(s). ForceBeta=$ForceBeta PageSize=$PageSize" if (-not $OutPath) { return $accessReviewsExport } # Use central helper for non-stream writes if ($accessReviewsExport) { if ($Append) { Write-TmfExportFile -OutPath $OutPath -ResourceName $resourceName -Data $accessReviewsExport -Append } else { Write-TmfExportFile -OutPath $OutPath -ResourceName $resourceName -Data $accessReviewsExport } } else { Write-PSFMessage -Level Warning -Message "No access review data found to export." } } } } |