Private/ConvertTo-MsecRolePrincipalRow.ps1

function ConvertTo-MsecRolePrincipalRow {
    <#
    .SYNOPSIS
        The projection for "a principal holds a directory role" - one row of
        Get-MsecEntraRoleHolder's output.

    .DESCRIPTION
        Separated from its caller for two reasons: Get-MsecEntraRoleHolder is long
        enough without it, and the row shape is the part most worth testing
        directly - every column here is a claim about privileged access, and a
        test can pin all of them without mocking a tenant's worth of Graph
        endpoints.

        It also has some history worth knowing. msec used to have two commands
        reading role membership from different endpoints, each building its own
        rows, and their columns had drifted apart: the same fact was called
        MemberId in one and PrincipalId in the other, so no single Where-Object
        filtered both. Consolidating the projection here came first; retiring the
        redundant command came after, once it was clear the shapes were the only
        real difference between them.

        TWO PRINCIPALS PER ROW is the shape, and the reason the row is not just a
        flat member record:

          PrincipalName / PrincipalType / PrincipalId
              What the role is ASSIGNED TO. A group whenever a group holds the
              role, and the object you would act on to revoke the assignment.

          EffectiveName / EffectiveType / EffectiveId
              Who ends up HOLDING it. The same object as the assignee on a direct
              assignment; a member for an expanded group; $null when the holders
              of an assigned group are not known.

        A row with PrincipalType 'group' and no EffectiveId is therefore an
        assignment whose holders were not determined - unreadable, empty, or
        -NoGroupExpansion - and never a claim that nobody holds the role.

    .PARAMETER Context
        Hashtable of the per-assignment facts the caller already resolved:
        RoleName, RoleTemplateId, IsHighlyPrivileged, AssignmentType, PrincipalId
        (the assignee id fallback), Scope, DirectoryScopeId, IsTenantScoped,
        EndDateTime, Raw.

    .PARAMETER Assignee
        The Graph principal the role is assigned to.

    .PARAMETER Effective
        The Graph principal that ends up holding the role, or $null when that is
        not known. Pass the same object as -Assignee for a direct assignment.

    .PARAMETER MembershipType
        How the effective principal sits inside the assignee - 'Active' or
        'Eligible'. Omit when holder and assignee are the same object; the row
        then reports $null, which is how a direct assignment is identified.

    .PARAMETER Stats
        Optional hashtable with UnreadablePrincipals and UnknownHolders keys,
        incremented in place so the caller can report the totals once at the end
        of a run instead of warning per row. A blank name in an access review reads
        as a broken report rather than as 'unknown', so those counts are what let a
        caller say which it is.

    .OUTPUTS
        One PSCustomObject with PSTypeName 'MsecEntraRoleHolder'.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Context,

        [Parameter()]
        $Assignee,

        [Parameter()]
        $Effective,

        [Parameter()]
        [ValidateSet('Active', 'Eligible')]
        [string] $MembershipType,

        [Parameter()]
        [hashtable] $Stats
    )

    $assigneeType = Get-MsecPrincipalObjectType $Assignee
    $assigneeId   = if ($Assignee -and $Assignee.id) { $Assignee.id } else { $Context.PrincipalId }
    $assigneeName = Get-MsecPrincipalDisplayKey -Principal $Assignee -FallbackId $assigneeId

    $effectiveType = Get-MsecPrincipalObjectType $Effective
    $effectiveId   = if ($Effective) { $Effective.id } else { $null }
    $effectiveName = if ($Effective) { Get-MsecPrincipalDisplayKey -Principal $Effective -FallbackId $effectiveId } else { $null }

    # Resolved means we know who ends up with the privilege. An assigned group whose
    # membership was not determined fails this, and so does a principal Graph
    # returned as an id-only shell - two different causes, one honest verdict.
    $unnamed    = ($null -ne $Effective) -and (Test-MsecPrincipalUnnamed $Effective)
    $isResolved = ($null -ne $Effective) -and -not $unnamed

    if ($Stats) {
        # Counted apart, because the fix differs: an identity the app cannot name
        # needs a permission grant, an assigned group we did not look inside needs
        # expansion (or is simply this endpoint's documented limit).
        if ($unnamed)             { $Stats.UnreadablePrincipals++ }
        if ($null -eq $Effective) { $Stats.UnknownHolders++ }
    }

    # The detail columns describe the EFFECTIVE principal - the identity that holds
    # the role and would be remediated - not the group it came through.
    $userType = if ($effectiveType -eq 'user' -and -not $unnamed) { $Effective.userType } else { $null }

    # Graph reports cloud-only objects as null here rather than false, so the two
    # cases have to be told apart deliberately. A synced privileged account means
    # on-premises Active Directory is a path to tenant admin, which is why
    # Microsoft's own guidance is that admin accounts be cloud-only.
    $isSynced = if ($effectiveType -ne 'user' -or $unnamed) { $null }
                else { [bool]$Effective.onPremisesSyncEnabled }

    # Two links must both be active to use a role held through a group: the role ->
    # principal assignment, and the effective principal's membership of it.
    # AssignmentType and MembershipType each stay faithful to their own link; this
    # is the answer to "can this identity use the role right now", which neither
    # column can give alone. $null where nobody is known to hold it.
    $isActiveNow = if (-not $isResolved) { $null }
                   else { ($Context.AssignmentType -eq 'Active') -and ($MembershipType -ne 'Eligible') }

    [PSCustomObject]@{
        PSTypeName         = 'MsecEntraRoleHolder'

        RoleName           = $Context.RoleName
        RoleTemplateId     = $Context.RoleTemplateId
        IsHighlyPrivileged = $Context.IsHighlyPrivileged

        # Who ends up with the privilege.
        EffectiveName      = $effectiveName
        EffectiveType      = $effectiveType
        EffectiveId        = $effectiveId

        # What the role is assigned to.
        PrincipalName      = $assigneeName
        PrincipalType      = $assigneeType
        PrincipalId        = $assigneeId

        AssignmentType     = $Context.AssignmentType
        MembershipType     = if ($MembershipType) { $MembershipType } else { $null }
        IsActiveNow        = $isActiveNow

        DisplayName        = $Effective.displayName
        UserPrincipalName  = $Effective.userPrincipalName
        AccountEnabled     = $Effective.accountEnabled
        UserType           = $userType
        IsDirectorySynced  = $isSynced

        Scope              = $Context.Scope
        DirectoryScopeId   = $Context.DirectoryScopeId
        IsTenantScoped     = $Context.IsTenantScoped
        EndDateTime        = $Context.EndDateTime

        IsResolved         = $isResolved

        Raw                = $Context.Raw
    }
}