Private/21-GetAtlasGroups.ps1

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

        [switch] $SkipMembersAndOwners
    )

    $result = [AtlasCollectionResult]::new()
    $endpoint = '/v1.0/groups?$select=id,displayName,description,groupTypes,mailEnabled,securityEnabled,isAssignableToRole,membershipRule'
    $response = Invoke-AtlasGraphRequest -Uri $endpoint
    $nestedGroupMembershipCount = 0
    if ($SkipMembersAndOwners) {
        $result.Status = 'partial'
        $result.Warnings.Add('Group member and owner collection was skipped by request.')
    }

    $groupIndex = 0
    foreach ($group in $response.Items) {
        $groupIndex++
        $groupNode = New-AtlasNode -TenantId $TenantId -Id $group.id -Kind 'group' -DisplayName $group.displayName -Properties @{
            description = $group.description
            groupTypes = @($group.groupTypes)
            mailEnabled = $group.mailEnabled
            securityEnabled = $group.securityEnabled
            isAssignableToRole = $group.isAssignableToRole
            membershipRule = $group.membershipRule
        } -Source @{
            provider = 'microsoftGraph'
            apiVersion = 'v1.0'
            odataType = '#microsoft.graph.group'
            resourcePath = "/groups/$($group.id)"
            collector = 'groups'
        }
        $result.Nodes.Add($groupNode)

        if ($SkipMembersAndOwners) {
            Update-AtlasProgressItem `
                -CurrentItem $groupIndex `
                -TotalItems $response.Items.Count `
                -Status 'Group members and owners skipped'
            continue
        }

        $membersEndpoint = "/v1.0/groups/$($group.id)/members?`$select=id,displayName"
        try {
            $membersResponse = Invoke-AtlasGraphRequest -Uri $membersEndpoint
            $response.Metrics.requestCount += $membersResponse.Metrics.requestCount
            $response.Metrics.retryCount += $membersResponse.Metrics.retryCount
        }
        catch {
            $result.Status = 'partial'
            $result.Warnings.Add("Members could not be collected for group '$($group.displayName)': $(Get-AtlasSafeErrorDetail -ErrorRecord $_)")
            Update-AtlasProgressItem `
                -CurrentItem $groupIndex `
                -TotalItems $response.Items.Count `
                -Status 'Group member request failed'
            continue
        }

        foreach ($member in $membersResponse.Items) {
            $memberId = Get-AtlasResponseProperty -InputObject $member -Name 'id'
            if (-not $memberId) {
                $result.Status = 'partial'
                $result.Warnings.Add("A member returned for group '$($group.displayName)' did not include an ID and was skipped.")
                continue
            }

            $odataType = Get-AtlasResponseProperty -InputObject $member -Name '@odata.type'
            $memberDisplayName = Get-AtlasResponseProperty -InputObject $member -Name 'displayName'
            $memberKind = switch ($odataType) {
                '#microsoft.graph.user' { 'user' }
                '#microsoft.graph.group' { 'group' }
                '#microsoft.graph.servicePrincipal' { 'servicePrincipal' }
                '#microsoft.graph.device' { 'device' }
                default { 'directoryObject' }
            }
            if ($memberKind -eq 'group') {
                $nestedGroupMembershipCount++
            }

            $memberKey = "tenant:$TenantId`:graph:$memberId"
            if (-not ($result.Nodes.Key -contains $memberKey)) {
                $memberName = if ($memberDisplayName) { $memberDisplayName } else { $memberId }
                $result.Nodes.Add(
                    (New-AtlasNode -TenantId $TenantId -Id $memberId -Kind $memberKind -DisplayName $memberName -Status 'partial' -Source @{
                        provider = 'microsoftGraph'
                        apiVersion = 'v1.0'
                        odataType = $odataType
                        resourcePath = "/directoryObjects/$memberId"
                        collector = 'groupMemberships'
                    })
                )
            }

            $evidence = New-AtlasEvidence -TenantId $TenantId -Collector 'groupMemberships' -Endpoint $membersEndpoint -SourceObjectId "$memberId|$($group.id)" -Fields @{
                memberId = $memberId
                groupId = $group.id
                assignment = 'direct'
            }
            $result.Evidence.Add($evidence)
            $result.Edges.Add(
                (New-AtlasEdge -TenantId $TenantId -From $memberKey -To $groupNode.Key -Relationship 'memberOf' -State @{
                    assignment = 'direct'
                    membershipType = if ($memberKind -eq 'group') { 'nestedGroup' } else { 'directMember' }
                } -EvidenceIds @($evidence.Key) -Source @{
                    collector = 'groupMemberships'
                })
            )
        }

        $ownersEndpoint = "/v1.0/groups/$($group.id)/owners?`$select=id,displayName"
        try {
            $ownersResponse = Invoke-AtlasGraphRequest -Uri $ownersEndpoint
            $response.Metrics.requestCount += $ownersResponse.Metrics.requestCount
            $response.Metrics.retryCount += $ownersResponse.Metrics.retryCount
        }
        catch {
            $result.Status = 'partial'
            $result.Warnings.Add("Owners could not be collected for group '$($group.displayName)': $(Get-AtlasSafeErrorDetail -ErrorRecord $_)")
            Update-AtlasProgressItem `
                -CurrentItem $groupIndex `
                -TotalItems $response.Items.Count `
                -Status 'Group owner request failed'
            continue
        }

        foreach ($owner in $ownersResponse.Items) {
            $ownerId = Get-AtlasResponseProperty -InputObject $owner -Name 'id'
            if (-not $ownerId) {
                continue
            }

            $odataType = Get-AtlasResponseProperty -InputObject $owner -Name '@odata.type'
            $ownerDisplayName = Get-AtlasResponseProperty -InputObject $owner -Name 'displayName'
            $ownerKind = switch ($odataType) {
                '#microsoft.graph.user' { 'user' }
                '#microsoft.graph.group' { 'group' }
                '#microsoft.graph.servicePrincipal' { 'servicePrincipal' }
                default { 'directoryObject' }
            }

            $ownerKey = "tenant:$TenantId`:graph:$ownerId"
            if (-not ($result.Nodes.Key -contains $ownerKey)) {
                $ownerName = if ($ownerDisplayName) { $ownerDisplayName } else { $ownerId }
                $result.Nodes.Add(
                    (New-AtlasNode -TenantId $TenantId -Id $ownerId -Kind $ownerKind -DisplayName $ownerName -Status 'partial' -Source @{
                        provider = 'microsoftGraph'
                        apiVersion = 'v1.0'
                        odataType = $odataType
                        resourcePath = "/directoryObjects/$ownerId"
                        collector = 'groupOwners'
                    })
                )
            }

            $evidence = New-AtlasEvidence -TenantId $TenantId -Collector 'groupOwners' -Endpoint $ownersEndpoint -SourceObjectId "$($group.id)|owner|$ownerId" -Fields @{
                groupId = $group.id
                ownerId = $ownerId
            }
            $result.Evidence.Add($evidence)
            $result.Edges.Add(
                (New-AtlasEdge -TenantId $TenantId -From $groupNode.Key -To $ownerKey -Relationship 'ownedBy' -State @{
                    ownerId = $ownerId
                } -EvidenceIds @($evidence.Key) -Source @{
                    collector = 'groupOwners'
                })
            )
        }
        Update-AtlasProgressItem `
            -CurrentItem $groupIndex `
            -TotalItems $response.Items.Count `
            -Status 'Group members and owners'
    }

    $result.Metrics = @{
        groupCount = $response.Items.Count
        nestedGroupMembershipCount = $nestedGroupMembershipCount
        membersAndOwnersSkipped = [bool] $SkipMembersAndOwners
        requestCount = $response.Metrics.requestCount
        retryCount = $response.Metrics.retryCount
    }
    return $result
}