Private/30-GetAtlasAccessReviews.ps1

function Get-AtlasGraphIdsFromQuery {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [string] $Query
    )

    if ([string]::IsNullOrWhiteSpace($Query)) {
        return @()
    }

    return @(
        [regex]::Matches($Query, '(?i)(?:groups|users|servicePrincipals|applications)/([0-9a-f-]{8,})') |
            ForEach-Object { $_.Groups[1].Value } |
            Sort-Object -Unique
    )
}

function Get-AtlasAccessPackageIdsFromQuery {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [string] $Query
    )

    if ([string]::IsNullOrWhiteSpace($Query)) {
        return @()
    }

    return @(
        [regex]::Matches($Query, '(?i)accessPackage(?:Id|/id)?\s+(?:eq\s+)?[''"]?([0-9a-f-]{8,})') |
            ForEach-Object { $_.Groups[1].Value } |
            Sort-Object -Unique
    )
}

function Get-AtlasAccessReview {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $TenantId,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [AtlasNode[]] $KnownNode
    )

    $result = [AtlasCollectionResult]::new()
    $keyById = @{}
    $addedNode = @{}
    foreach ($node in $KnownNode) {
        $keyById[$node.Id] = $node.Key
        foreach ($propertyName in @('accessPackageId', 'assignmentPolicyId', 'catalogId')) {
            if ($node.Properties.ContainsKey($propertyName) -and $node.Properties[$propertyName]) {
                $keyById[$node.Properties[$propertyName]] = $node.Key
            }
        }
    }

    $definitionsEndpoint = '/v1.0/identityGovernance/accessReviews/definitions'
    $definitionResponse = Invoke-AtlasGraphRequest -Uri $definitionsEndpoint
    $requestCount = $definitionResponse.Metrics.requestCount
    $retryCount = $definitionResponse.Metrics.retryCount
    $instanceCount = 0
    $decisionCount = 0
    $coveredTargetCount = 0
    $reviewerEdgeCount = 0

    foreach ($definition in $definitionResponse.Items) {
        $scope = Get-AtlasResponseProperty -InputObject $definition -Name 'scope'
        $instanceEnumerationScope = Get-AtlasResponseProperty -InputObject $definition -Name 'instanceEnumerationScope'
        $scopeQuery = Get-AtlasResponseProperty -InputObject $scope -Name 'query'
        $enumerationQuery = Get-AtlasResponseProperty -InputObject $instanceEnumerationScope -Name 'query'
        $definitionNode = New-AtlasNode -TenantId $TenantId -Id "accessReviewDefinition:$($definition.id)" -Kind 'accessReviewDefinition' -DisplayName $definition.displayName -Properties @{
            accessReviewDefinitionId = $definition.id
            descriptionForAdmins = Get-AtlasResponseProperty -InputObject $definition -Name 'descriptionForAdmins'
            descriptionForReviewers = Get-AtlasResponseProperty -InputObject $definition -Name 'descriptionForReviewers'
            status = Get-AtlasResponseProperty -InputObject $definition -Name 'status'
            createdDateTime = Get-AtlasResponseProperty -InputObject $definition -Name 'createdDateTime'
            lastModifiedDateTime = Get-AtlasResponseProperty -InputObject $definition -Name 'lastModifiedDateTime'
            scope = $scope
            instanceEnumerationScope = $instanceEnumerationScope
            settings = Get-AtlasResponseProperty -InputObject $definition -Name 'settings'
            reviewers = @(Get-AtlasResponseProperty -InputObject $definition -Name 'reviewers')
            fallbackReviewers = @(Get-AtlasResponseProperty -InputObject $definition -Name 'fallbackReviewers')
        } -Source @{
            provider = 'microsoftGraph'
            apiVersion = 'v1.0'
            odataType = '#microsoft.graph.accessReviewScheduleDefinition'
            resourcePath = "/identityGovernance/accessReviews/definitions/$($definition.id)"
            collector = 'accessReviews'
        } -Tags @('governance', 'accessReview')
        $result.Nodes.Add($definitionNode)

        $targetId = @(
            Get-AtlasGraphIdsFromQuery -Query $scopeQuery
            Get-AtlasGraphIdsFromQuery -Query $enumerationQuery
            Get-AtlasAccessPackageIdsFromQuery -Query $scopeQuery
            Get-AtlasAccessPackageIdsFromQuery -Query $enumerationQuery
        ) | Sort-Object -Unique
        foreach ($id in $targetId) {
            if (-not $keyById.ContainsKey($id)) { continue }
            $evidence = New-AtlasEvidence -TenantId $TenantId -Collector 'accessReviews' -Endpoint $definitionsEndpoint -SourceObjectId "$($definition.id)|target|$id" -Fields @{
                accessReviewDefinitionId = $definition.id
                targetId = $id
                scopeQuery = $scopeQuery
                instanceEnumerationQuery = $enumerationQuery
            }
            $result.Evidence.Add($evidence)
            $result.Edges.Add(
                (New-AtlasEdge -TenantId $TenantId -From $keyById[$id] -To $definitionNode.Key -Relationship 'coveredByAccessReview' -State @{
                    reviewStatus = Get-AtlasResponseProperty -InputObject $definition -Name 'status'
                } -EvidenceIds @($evidence.Key) -Source @{ collector = 'accessReviews' })
            )
            $coveredTargetCount++
        }

        $reviewerSets = @(
            @{ Items = @(Get-AtlasResponseProperty -InputObject $definition -Name 'reviewers'); Type = 'primary' }
            @{ Items = @(Get-AtlasResponseProperty -InputObject $definition -Name 'fallbackReviewers'); Type = 'fallback' }
        )
        foreach ($reviewerSet in $reviewerSets) {
            foreach ($reviewerScope in $reviewerSet.Items) {
                if ($null -eq $reviewerScope) { continue }
                $query = Get-AtlasResponseProperty -InputObject $reviewerScope -Name 'query'
                $reviewerId = @(Get-AtlasGraphIdsFromQuery -Query $query)
                if ($reviewerId.Count -eq 0) {
                    $scopeId = "accessReviewReviewerScope:$(Get-AtlasStableId -InputString "$($definition.id)|$($reviewerSet.Type)|$query")"
                    $scopeKey = "tenant:$TenantId`:graph:$scopeId"
                    if (-not $addedNode.ContainsKey($scopeKey)) {
                        $scopeName = if ($query) { $query } else { 'Self review' }
                        $scopeNode = New-AtlasNode -TenantId $TenantId -Id $scopeId -Kind 'accessReviewReviewerScope' -DisplayName $scopeName -Properties @{
                            query = $query
                            queryRoot = Get-AtlasResponseProperty -InputObject $reviewerScope -Name 'queryRoot'
                            queryType = Get-AtlasResponseProperty -InputObject $reviewerScope -Name 'queryType'
                            reviewerType = $reviewerSet.Type
                        } -Source @{
                            provider = 'microsoftGraph'
                            apiVersion = 'v1.0'
                            odataType = '#microsoft.graph.accessReviewReviewerScope'
                            resourcePath = "/identityGovernance/accessReviews/definitions/$($definition.id)"
                            collector = 'accessReviews'
                        } -Tags @('governance', 'reviewerScope')
                        $result.Nodes.Add($scopeNode)
                        $addedNode[$scopeKey] = $true
                    }
                    $reviewerId = @($scopeId)
                    $keyById[$scopeId] = $scopeKey
                }

                foreach ($id in $reviewerId) {
                    if (-not $keyById.ContainsKey($id)) { continue }
                    $evidence = New-AtlasEvidence -TenantId $TenantId -Collector 'accessReviews' -Endpoint $definitionsEndpoint -SourceObjectId "$($definition.id)|reviewer|$id|$($reviewerSet.Type)" -Fields @{
                        accessReviewDefinitionId = $definition.id
                        reviewerId = $id
                        reviewerType = $reviewerSet.Type
                        query = $query
                    }
                    $result.Evidence.Add($evidence)
                    $result.Edges.Add(
                        (New-AtlasEdge -TenantId $TenantId -From $keyById[$id] -To $definitionNode.Key -Relationship 'reviewsAccess' -State @{
                            reviewerType = $reviewerSet.Type
                        } -EvidenceIds @($evidence.Key) -Source @{ collector = 'accessReviews' })
                    )
                    $reviewerEdgeCount++
                }
            }
        }

        $instancesEndpoint = "/v1.0/identityGovernance/accessReviews/definitions/$($definition.id)/instances"
        try {
            $instanceResponse = Invoke-AtlasGraphRequest -Uri $instancesEndpoint
            $requestCount += $instanceResponse.Metrics.requestCount
            $retryCount += $instanceResponse.Metrics.retryCount
        }
        catch {
            $result.Status = 'partial'
            $result.Warnings.Add("Instances could not be collected for access review '$($definition.displayName)': $(Get-AtlasSafeErrorDetail -ErrorRecord $_)")
            continue
        }

        foreach ($instance in $instanceResponse.Items) {
            $instanceNode = New-AtlasNode -TenantId $TenantId -Id "accessReviewInstance:$($instance.id)" -Kind 'accessReviewInstance' -DisplayName "$($definition.displayName) instance" -Properties @{
                accessReviewInstanceId = $instance.id
                definitionId = $definition.id
                status = Get-AtlasResponseProperty -InputObject $instance -Name 'status'
                startDateTime = Get-AtlasResponseProperty -InputObject $instance -Name 'startDateTime'
                endDateTime = Get-AtlasResponseProperty -InputObject $instance -Name 'endDateTime'
                reviewedEntity = Get-AtlasResponseProperty -InputObject $instance -Name 'reviewedEntity'
            } -Source @{
                provider = 'microsoftGraph'
                apiVersion = 'v1.0'
                odataType = '#microsoft.graph.accessReviewInstance'
                resourcePath = "/identityGovernance/accessReviews/definitions/$($definition.id)/instances/$($instance.id)"
                collector = 'accessReviews'
            } -Tags @('governance', 'accessReviewInstance')
            $result.Nodes.Add($instanceNode)

            $instanceEvidence = New-AtlasEvidence -TenantId $TenantId -Collector 'accessReviews' -Endpoint $instancesEndpoint -SourceObjectId $instance.id -Fields @{
                accessReviewDefinitionId = $definition.id
                accessReviewInstanceId = $instance.id
                status = Get-AtlasResponseProperty -InputObject $instance -Name 'status'
                startDateTime = Get-AtlasResponseProperty -InputObject $instance -Name 'startDateTime'
                endDateTime = Get-AtlasResponseProperty -InputObject $instance -Name 'endDateTime'
            }
            $result.Evidence.Add($instanceEvidence)
            $result.Edges.Add(
                (New-AtlasEdge -TenantId $TenantId -From $definitionNode.Key -To $instanceNode.Key -Relationship 'hasAccessReviewInstance' -EvidenceIds @($instanceEvidence.Key) -Source @{ collector = 'accessReviews' })
            )
            $instanceCount++

            $decisionsEndpoint = "/v1.0/identityGovernance/accessReviews/definitions/$($definition.id)/instances/$($instance.id)/decisions"
            try {
                $decisionResponse = Invoke-AtlasGraphRequest -Uri $decisionsEndpoint
                $requestCount += $decisionResponse.Metrics.requestCount
                $retryCount += $decisionResponse.Metrics.retryCount
            }
            catch {
                $result.Status = 'partial'
                $result.Warnings.Add("Decisions could not be collected for an instance of access review '$($definition.displayName)': $(Get-AtlasSafeErrorDetail -ErrorRecord $_)")
                continue
            }

            foreach ($decision in $decisionResponse.Items) {
                $principal = Get-AtlasResponseProperty -InputObject $decision -Name 'principal'
                $resource = Get-AtlasResponseProperty -InputObject $decision -Name 'resource'
                $principalId = Get-AtlasResponseProperty -InputObject $principal -Name 'id'
                $resourceId = Get-AtlasResponseProperty -InputObject $resource -Name 'id'
                if (-not $principalId) { continue }

                if ($keyById.ContainsKey($principalId)) {
                    $principalKey = $keyById[$principalId]
                }
                else {
                    $principalKey = "tenant:$TenantId`:graph:$principalId"
                    if (-not $addedNode.ContainsKey($principalKey)) {
                        $principalName = Get-AtlasResponseProperty -InputObject $principal -Name 'displayName'
                        if (-not $principalName) { $principalName = $principalId }
                        $principalNode = New-AtlasNode -TenantId $TenantId -Id $principalId -Kind 'directoryObject' -DisplayName $principalName -Status 'partial' -Source @{
                            provider = 'microsoftGraph'
                            apiVersion = 'v1.0'
                            resourcePath = Get-AtlasResponseProperty -InputObject $decision -Name 'principalLink'
                            collector = 'accessReviews'
                        }
                        $result.Nodes.Add($principalNode)
                        $addedNode[$principalKey] = $true
                    }
                    $keyById[$principalId] = $principalKey
                }

                $evidence = New-AtlasEvidence -TenantId $TenantId -Collector 'accessReviews' -Endpoint $decisionsEndpoint -SourceObjectId $decision.id -Fields @{
                    decisionId = $decision.id
                    principalId = $principalId
                    resourceId = $resourceId
                    decision = Get-AtlasResponseProperty -InputObject $decision -Name 'decision'
                    recommendation = Get-AtlasResponseProperty -InputObject $decision -Name 'recommendation'
                    justification = Get-AtlasResponseProperty -InputObject $decision -Name 'justification'
                    reviewedBy = Get-AtlasResponseProperty -InputObject $decision -Name 'reviewedBy'
                    reviewedDateTime = Get-AtlasResponseProperty -InputObject $decision -Name 'reviewedDateTime'
                    applyResult = Get-AtlasResponseProperty -InputObject $decision -Name 'applyResult'
                    appliedDateTime = Get-AtlasResponseProperty -InputObject $decision -Name 'appliedDateTime'
                }
                $result.Evidence.Add($evidence)
                $result.Edges.Add(
                    (New-AtlasEdge -TenantId $TenantId -From $principalKey -To $instanceNode.Key -Relationship 'reviewedInAccessReview' -State @{
                        resourceId = $resourceId
                        decision = Get-AtlasResponseProperty -InputObject $decision -Name 'decision'
                        recommendation = Get-AtlasResponseProperty -InputObject $decision -Name 'recommendation'
                        reviewedDateTime = Get-AtlasResponseProperty -InputObject $decision -Name 'reviewedDateTime'
                        applyResult = Get-AtlasResponseProperty -InputObject $decision -Name 'applyResult'
                    } -EvidenceIds @($evidence.Key) -Source @{ collector = 'accessReviews' })
                )
                if ($resourceId -and $keyById.ContainsKey($resourceId)) {
                    $result.Edges.Add(
                        (New-AtlasEdge -TenantId $TenantId -From $keyById[$resourceId] -To $instanceNode.Key -Relationship 'resourceReviewedInAccessReview' -State @{
                            principalId = $principalId
                            decision = Get-AtlasResponseProperty -InputObject $decision -Name 'decision'
                        } -EvidenceIds @($evidence.Key) -Source @{ collector = 'accessReviews' })
                    )
                }
                $decisionCount++
            }
        }
    }

    $result.Metrics = @{
        definitionCount = $definitionResponse.Items.Count
        instanceCount = $instanceCount
        decisionCount = $decisionCount
        coveredTargetCount = $coveredTargetCount
        reviewerEdgeCount = $reviewerEdgeCount
        requestCount = $requestCount
        retryCount = $retryCount
    }
    return $result
}