#requires -Version 7.0 <# .SYNOPSIS Export all active and PIM-eligible memberships in Microsoft Entra directory roles, including expansion of group-assigned roles to transitive group members, and mark whether each role is privileged. .DESCRIPTION Uses Microsoft Graph PowerShell (Microsoft.Graph.Authentication) and the beta roleManagement endpoints so the isPrivileged flag is available for both built-in and custom roles. Follows this toolkit's standard -AuthMode / -AppId / -Secret / -Certificate auth convention (see Deploy-CISM365Baseline.ps1). .REQUIREMENTS Install-Module Microsoft.Graph.Authentication -Scope CurrentUser Install-Module Microsoft.Graph.Identity.Governance -Scope CurrentUser Install-Module Microsoft.Graph.DirectoryObjects -Scope CurrentUser Install-Module Microsoft.Graph.Groups -Scope CurrentUser .PERMISSIONS Suggested Graph scopes / app roles: RoleManagement.Read.Directory Directory.Read.All GroupMember.Read.All .EXAMPLE ./Scripts/Export-EntraRoleMembership.ps1 -TenantId -AuthMode Browser .EXAMPLE ./Scripts/Export-EntraRoleMembership.ps1 -TenantId -AuthMode AppOnly -AppId -Secret -CsvPath ./reports/roles.csv #> [CmdletBinding()] param( [Parameter()] [string]$TenantId, [Parameter()] [string]$AppId, [Parameter()] [string]$Secret, [Parameter()] [string]$Certificate, [Parameter()] [ValidateSet('AppOnly', 'Browser', 'DeviceCode')] [string]$AuthMode = 'Browser', [Parameter()] [string]$SettingsFile, [Parameter()] [string]$CsvPath ) $ErrorActionPreference = "Stop" #region Helpers function Get-ObjectType { param([object]$Object) if ($null -eq $Object.AdditionalProperties.'@odata.type') { return $Object.GetType().Name } return ($Object.AdditionalProperties.'@odata.type' -replace '^#microsoft\.graph\.', '') } function Get-DisplayNameSafe { param([object]$Object) if ($Object.PSObject.Properties.Name -contains 'DisplayName' -and $Object.DisplayName) { return $Object.DisplayName } if ($Object.AdditionalProperties -and $Object.AdditionalProperties.ContainsKey('displayName')) { return $Object.AdditionalProperties['displayName'] } return $null } function Get-UpnSafe { param([object]$Object) if ($Object.PSObject.Properties.Name -contains 'UserPrincipalName' -and $Object.UserPrincipalName) { return $Object.UserPrincipalName } if ($Object.AdditionalProperties -and $Object.AdditionalProperties.ContainsKey('userPrincipalName')) { return $Object.AdditionalProperties['userPrincipalName'] } return $null } function Get-MailSafe { param([object]$Object) if ($Object.PSObject.Properties.Name -contains 'Mail' -and $Object.Mail) { return $Object.Mail } if ($Object.AdditionalProperties -and $Object.AdditionalProperties.ContainsKey('mail')) { return $Object.AdditionalProperties['mail'] } return $null } function Invoke-GraphGetAllPages { param( [Parameter(Mandatory)] [string]$Uri ) $items = @() $nextLink = $Uri while ($nextLink) { $response = Invoke-MgGraphRequest -Method GET -Uri $nextLink -OutputType PSObject if ($response.value) { $items += $response.value } else { $items += $response break } $nextLink = $response.'@odata.nextLink' } return $items } #endregion #region Load saved AppId/Secret (same lookup as Invoke-IntuneHeadlessBatch) if ($AuthMode -eq 'AppOnly' -and $TenantId -and (-not $AppId -or (-not $Secret -and -not $Certificate))) { $coreModule = Join-Path (Split-Path -Parent $PSScriptRoot) "Core.psm1" if (Test-Path $coreModule) { Import-Module $coreModule -Force -Global $settingsPath = if ($SettingsFile) { $SettingsFile } else { Join-Path (Get-CloudApiDataFolder) "Settings.json" } if (Test-Path $settingsPath) { try { $raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop $settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop if ($settingsObj -and $settingsObj.ContainsKey($TenantId)) { $tenantNode = $settingsObj[$TenantId] if (-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId")) { $AppId = $tenantNode["GraphAzureAppId"] } if (-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret")) { $Secret = $tenantNode["GraphAzureAppSecret"] } if (-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert")) { $Certificate = $tenantNode["GraphAzureAppCert"] } } if (-not $Secret -and $IsMacOS -and $AppId) { try { $keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null if ($keychainSecret) { $Secret = $keychainSecret } } catch { } } } catch { } } } } #endregion #region Auth $requiredModules = @( "Microsoft.Graph.Authentication", "Microsoft.Graph.Identity.Governance", "Microsoft.Graph.DirectoryObjects", "Microsoft.Graph.Groups" ) foreach ($mod in $requiredModules) { if (-not (Get-Module $mod -ListAvailable)) { throw "Module '$mod' is not installed. Run: Install-Module $mod -Scope CurrentUser" } Import-Module $mod -Force } $GraphScopes = @("RoleManagement.Read.Directory", "Directory.Read.All", "GroupMember.Read.All") Write-Host "Connecting to Microsoft Graph (mode: $AuthMode)..." -NoNewline $connectParams = @{} if ($TenantId) { $connectParams.TenantId = $TenantId } switch ($AuthMode) { 'AppOnly' { if (-not $AppId) { throw "AppId is required for AppOnly auth mode." } if ($Secret) { $secureSecret = ConvertTo-SecureString -String $Secret -AsPlainText -Force $credential = New-Object System.Management.Automation.PSCredential($AppId, $secureSecret) $connectParams.ClientSecretCredential = $credential } elseif ($Certificate) { $cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $Certificate -or $_.Subject -eq $Certificate } | Select-Object -First 1 if (-not $cert) { throw "Certificate not found: $Certificate" } $connectParams.ClientCertificateCredential = $cert } else { throw "Secret or Certificate is required for AppOnly auth mode." } Connect-MgGraph @connectParams -NoWelcome } 'DeviceCode' { Connect-MgGraph -Scopes ($GraphScopes -join ',') @connectParams -UseDeviceCode -NoWelcome } default { # Browser / Interactive Connect-MgGraph -Scopes ($GraphScopes -join ',') @connectParams -NoWelcome } } $context = Get-MgContext Write-Host " OK ($($context.Account))" -ForegroundColor Green #endregion Write-Host "Getting tenant information..." -ForegroundColor Cyan $org = Invoke-GraphGetAllPages -Uri "https://graph.microsoft.com/v1.0/organization?`$select=displayName,id" $tenantName = $org[0].displayName $tenantId = $org[0].id $invalidChars = [System.IO.Path]::GetInvalidFileNameChars() -join '' $escaped = [regex]::Escape($invalidChars) $safeTenantName = $tenantName -replace "[$escaped]", '_' $defaultFileName = "$safeTenantName-Entra-AllRoleMemberships.csv" if ([string]::IsNullOrWhiteSpace($CsvPath)) { $CsvPath = ".\$defaultFileName" } elseif ((Test-Path -LiteralPath $CsvPath -PathType Container) -or ($CsvPath -match '[\\/]$')) { $CsvPath = Join-Path $CsvPath $defaultFileName } $csvDir = Split-Path -Parent $CsvPath if ($csvDir -and -not (Test-Path -LiteralPath $csvDir)) { New-Item -ItemType Directory -Path $csvDir -Force | Out-Null } Write-Host "Tenant: $tenantName ($tenantId)" -ForegroundColor Cyan Write-Host "Export path: $CsvPath" -ForegroundColor Cyan Write-Host "" Write-Host "Getting all role definitions from Graph beta endpoint..." -ForegroundColor Cyan $roleDefsUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions" $allRoles = Invoke-GraphGetAllPages -Uri $roleDefsUri if (-not $allRoles) { Write-Warning "No role definitions were returned." return } Write-Host "Getting active role assignments from Graph beta endpoint..." -ForegroundColor Cyan $assignmentsUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?`$expand=roleDefinition" $assignments = Invoke-GraphGetAllPages -Uri $assignmentsUri Write-Host "Getting PIM-eligible role assignments from Graph beta endpoint..." -ForegroundColor Cyan $eligibilityUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules" try { $eligibleAssignments = Invoke-GraphGetAllPages -Uri $eligibilityUri } catch { $errorText = "$($_.Exception.Message) $($_.ErrorDetails.Message)" if ($errorText -match 'AadPremiumLicenseRequired') { Write-Warning "PIM eligibility schedules require Entra ID P2 or Governance license. Skipping eligible assignments." $eligibleAssignments = @() } elseif ($errorText -match 'PermissionScopeNotGranted|Authorization_RequestDenied|403') { Write-Warning "Missing permission for PIM eligibility schedules (needs RoleManagement.Read.Directory app role granted to this app registration). Skipping eligible assignments." $eligibleAssignments = @() } else { throw } } $activeWithState = foreach ($a in $assignments) { $a | Select-Object *, @{N = 'AssignmentState'; E = { 'Active' } } } $eligibleWithState = foreach ($e in $eligibleAssignments) { $e | Select-Object *, @{N = 'AssignmentState'; E = { 'Eligible' } } } $allAssignments = $activeWithState + $eligibleWithState Write-Host "Discovered $($allRoles.Count) role definitions, $($assignments.Count) active assignments, and $($eligibleAssignments.Count) eligible assignments." -ForegroundColor DarkGray $roleMap = @{} $rolePrivilegedMap = @{} foreach ($role in $allRoles) { $roleMap[$role.id] = $role.displayName $rolePrivilegedMap[$role.id] = [bool]$role.isPrivileged } $principalCache = @{} $groupMemberCache = @{} $results = New-Object System.Collections.Generic.List[object] foreach ($assignment in $allAssignments) { $roleId = $assignment.roleDefinitionId $roleName = $roleMap[$roleId] $isPrivileged = $rolePrivilegedMap[$roleId] # Fallback to the inline expanded role definition if the role wasn't in the catalog if (-not $roleName -and $assignment.roleDefinition) { $roleName = $assignment.roleDefinition.displayName $isPrivileged = [bool]$assignment.roleDefinition.isPrivileged } $principalId = $assignment.principalId $directoryScopeId = $assignment.directoryScopeId $assignmentState = $assignment.AssignmentState # Skip assignments whose role definition could not be resolved if (-not $roleName) { Write-Warning "Skipping assignment for unknown role definition $roleId" continue } if (-not $principalCache.ContainsKey($principalId)) { try { $principalCache[$principalId] = Get-MgDirectoryObjectById -Ids $principalId } catch { Write-Warning "Failed to resolve principal $principalId" continue } } $principal = $principalCache[$principalId] $principalType = Get-ObjectType -Object $principal $principalName = Get-DisplayNameSafe -Object $principal switch ($principalType.ToLower()) { "group" { if (-not $groupMemberCache.ContainsKey($principalId)) { try { $groupMemberCache[$principalId] = Get-MgGroupTransitiveMember -GroupId $principalId -All } catch { Write-Warning "Failed to expand group members for group $principalName ($principalId)" $groupMemberCache[$principalId] = @() } } $members = $groupMemberCache[$principalId] if (-not $members -or $members.Count -eq 0) { $results.Add([pscustomobject]@{ IsPrivileged = $isPrivileged AssignmentState = $assignmentState RoleName = $roleName RoleId = $roleId AssignmentType = "GroupAssignment" AssignmentPrincipalType = $principalType AssignmentPrincipal = $principalName AssignmentPrincipalId = $principalId ExpandedMemberType = $null ExpandedMemberName = $null ExpandedMemberUPN = $null ExpandedMemberMail = $null ExpandedMemberId = $null DirectoryScopeId = $directoryScopeId Notes = "Group assigned, but no transitive members returned" }) } else { foreach ($member in $members) { $memberType = Get-ObjectType -Object $member $memberName = Get-DisplayNameSafe -Object $member $memberUpn = Get-UpnSafe -Object $member $memberMail = Get-MailSafe -Object $member $results.Add([pscustomobject]@{ IsPrivileged = $isPrivileged AssignmentState = $assignmentState RoleName = $roleName RoleId = $roleId AssignmentType = "GroupAssignmentExpanded" AssignmentPrincipalType = $principalType AssignmentPrincipal = $principalName AssignmentPrincipalId = $principalId ExpandedMemberType = $memberType ExpandedMemberName = $memberName ExpandedMemberUPN = $memberUpn ExpandedMemberMail = $memberMail ExpandedMemberId = $member.Id DirectoryScopeId = $directoryScopeId Notes = "Expanded from group assignment" }) } } } default { $results.Add([pscustomobject]@{ IsPrivileged = $isPrivileged AssignmentState = $assignmentState RoleName = $roleName RoleId = $roleId AssignmentType = "DirectAssignment" AssignmentPrincipalType = $principalType AssignmentPrincipal = $principalName AssignmentPrincipalId = $principalId ExpandedMemberType = $principalType ExpandedMemberName = $principalName ExpandedMemberUPN = Get-UpnSafe -Object $principal ExpandedMemberMail = Get-MailSafe -Object $principal ExpandedMemberId = $principalId DirectoryScopeId = $directoryScopeId Notes = "Direct role assignment" }) } } } $results | Sort-Object IsPrivileged, AssignmentState, RoleName, AssignmentType, ExpandedMemberName | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8 Write-Host "" Write-Host "Exported $($results.Count) records to: $CsvPath" -ForegroundColor Green Write-Host "" $results | Sort-Object IsPrivileged, AssignmentState, RoleName, ExpandedMemberName | Format-Table IsPrivileged, AssignmentState, RoleName, AssignmentType, AssignmentPrincipal, ExpandedMemberName, ExpandedMemberUPN, ExpandedMemberType -AutoSize Disconnect-MgGraph | Out-Null