Files
macOS_IntuneManagement/Scripts/Export-EntraRoleMembership.ps1
tomas.kracmar 6bf7345eb7 release: v4.2.0 — Entra directory role membership export
- Add Scripts/Export-EntraRoleMembership.ps1 to export active + PIM-eligible
  Entra directory role memberships (with group expansion) to CSV.
- Wire the new script into Start-IntuneToolkit.ps1 as menu item 20.
- Document the new script in README.md and AGENTS.md.
- Bump VERSION to 4.2.0 and add CHANGELOG entry.
- Harden .gitignore against local operational artifacts (accounts/ folder,
  deploy.sh, restart_gateways.sh).
2026-07-07 14:57:02 +02:00

439 lines
16 KiB
PowerShell

#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 <tenant-id> -AuthMode Browser
.EXAMPLE
./Scripts/Export-EntraRoleMembership.ps1 -TenantId <tenant-id> -AuthMode AppOnly -AppId <app-id> -Secret <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