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).
This commit is contained in:
2026-07-07 14:57:02 +02:00
parent 90209a7ec4
commit 6bf7345eb7
7 changed files with 485 additions and 2 deletions
+5
View File
@@ -36,3 +36,8 @@ __pycache__/
*.pyo *.pyo
.venv-pdf/ .venv-pdf/
.venv/ .venv/
# Local operational artifacts that are not part of the toolkit
accounts/
deploy.sh
restart_gateways.sh
+1
View File
@@ -125,6 +125,7 @@ The launcher caches tenant display names in `Settings.json` so the TUI can show
| `Start-IntuneToolkit.ps1` | Unified reverse-numbered `fzf`/numbered menu; remembers tenants; launches all other tools. | | `Start-IntuneToolkit.ps1` | Unified reverse-numbered `fzf`/numbered menu; remembers tenants; launches all other tools. |
| `Scripts/Start-HeadlessIntune.ps1` | Single-action wrapper (`Export`, `Import`, `DeployCISBaseline`, `GenerateReports`) with optional interactive TUI. | | `Scripts/Start-HeadlessIntune.ps1` | Single-action wrapper (`Export`, `Import`, `DeployCISBaseline`, `GenerateReports`) with optional interactive TUI. |
| `Scripts/Export-SettingsReport.py` | Generate a flat CSV of policy settings/values. Includes a `Platform` column and resolves Settings Catalog names from `configurationSettings.json` (auto-exported with Settings Catalog). | | `Scripts/Export-SettingsReport.py` | Generate a flat CSV of policy settings/values. Includes a `Platform` column and resolves Settings Catalog names from `configurationSettings.json` (auto-exported with Settings Catalog). |
| `Scripts/Export-EntraRoleMembership.ps1` | Export active + PIM-eligible Microsoft Entra directory role memberships (group assignments expanded) to CSV. |
| `Scripts/Export-Policies.ps1` | Export policies to JSON. | | `Scripts/Export-Policies.ps1` | Export policies to JSON. |
| `Scripts/Import-Policies.ps1` | Import policies from JSON. | | `Scripts/Import-Policies.ps1` | Import policies from JSON. |
| `Scripts/Initialize-IntuneAuth.ps1` | One-time Entra app setup; also supports `-RotateSecret`, `-Delete`, `-DeleteApp`. | | `Scripts/Initialize-IntuneAuth.ps1` | One-time Entra app setup; also supports `-RotateSecret`, `-Delete`, `-DeleteApp`. |
+21
View File
@@ -1,5 +1,26 @@
# macOS Intune Toolkit Changelog # macOS Intune Toolkit Changelog
## 2026-06-23 — v4.2.0 — Entra directory role membership export
### Added
- **`Scripts/Export-EntraRoleMembership.ps1`**
- Exports all active and PIM-eligible Microsoft Entra directory role memberships to CSV.
- Expands group-assigned roles to transitive group members.
- Marks whether each role is privileged.
- Supports the toolkit's standard `-AuthMode` (`AppOnly`, `Browser`, `DeviceCode`) and reads saved credentials from `Settings.json`/macOS Keychain.
- **`Start-IntuneToolkit.ps1`**
- Added menu item **20. Export Entra role membership** that launches the new script and prompts for the CSV output path.
- **`README.md`**
- Listed `Scripts/Export-EntraRoleMembership.ps1` in the entry points and documented its purpose.
### Repository hygiene
- **`.gitignore`**
- Ignore the local `accounts/` folder and stray operational shell scripts (`deploy.sh`, `restart_gateways.sh`).
---
## 2026-06-22 — Expanded settings report coverage and Conditional Access documentation ## 2026-06-22 — Expanded settings report coverage and Conditional Access documentation
### Added ### Added
+2 -1
View File
@@ -2,7 +2,7 @@
Cross-platform, headless Intune policy export/import with PowerShell. Cross-platform, headless Intune policy export/import with PowerShell.
**Current version:** `4.1.0` — see [`CHANGELOG_macOS_IntuneToolkit.md`](CHANGELOG_macOS_IntuneToolkit.md) for recent changes. **Current version:** `4.2.0` — see [`CHANGELOG_macOS_IntuneToolkit.md`](CHANGELOG_macOS_IntuneToolkit.md) for recent changes.
This repository is now CLI-first. The old WPF application surface has been removed from the repo. The supported workflow is: This repository is now CLI-first. The old WPF application surface has been removed from the repo. The supported workflow is:
@@ -32,6 +32,7 @@ pwsh ./Start-IntuneToolkit.ps1 -TenantId "<tenant-id>"
* [Scripts/Import-Policies.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Import-Policies.ps1) * [Scripts/Import-Policies.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Import-Policies.ps1)
* [Scripts/Initialize-IntuneAuth.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1) — one-time Entra app + secret + Keychain setup * [Scripts/Initialize-IntuneAuth.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Initialize-IntuneAuth.ps1) — one-time Entra app + secret + Keychain setup
* [Scripts/Export-SettingsReport.py](/Users/avedelphina/Local/IntuneManagement/Scripts/Export-SettingsReport.py) — generate a flat CSV of policy settings/values * [Scripts/Export-SettingsReport.py](/Users/avedelphina/Local/IntuneManagement/Scripts/Export-SettingsReport.py) — generate a flat CSV of policy settings/values
* [Scripts/Export-EntraRoleMembership.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Export-EntraRoleMembership.ps1) — export all active + PIM-eligible Entra directory role memberships (group assignments expanded) to CSV
* [Headless/IntuneManagement.Headless.psd1](/Users/avedelphina/Local/IntuneManagement/Headless/IntuneManagement.Headless.psd1) * [Headless/IntuneManagement.Headless.psd1](/Users/avedelphina/Local/IntuneManagement/Headless/IntuneManagement.Headless.psd1)
## Runtime ## Runtime
+438
View File
@@ -0,0 +1,438 @@
#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
+17
View File
@@ -308,6 +308,7 @@ $commonParams = @{
} }
$menuItems = @( $menuItems = @(
"20. Export Entra role membership"
"19. Document Conditional Access policies" "19. Document Conditional Access policies"
"18. Rotate app secret" "18. Rotate app secret"
"17. Deploy CIS M365 baseline" "17. Deploy CIS M365 baseline"
@@ -514,6 +515,22 @@ while($true)
continue continue
} }
if($choiceNumber -eq 20)
{
$roleScript = Join-Path $projectRoot "Scripts/Export-EntraRoleMembership.ps1"
$roleParams = @{ TenantId = $TenantId; AuthMode = $AuthMode }
if($AppId) { $roleParams.AppId = $AppId }
if($Secret) { $roleParams.Secret = $Secret }
elseif($Certificate) { $roleParams.Certificate = $Certificate }
if($SettingsFile) { $roleParams.SettingsFile = $SettingsFile }
$csvOut = Read-Host "CSV output path (Enter for default: <TenantName>-Entra-AllRoleMemberships.csv)"
if(-not [string]::IsNullOrWhiteSpace($csvOut)) { $roleParams.CsvPath = $csvOut }
& $roleScript @roleParams
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
if($choiceNumber -eq 19) if($choiceNumber -eq 19)
{ {
$caScript = Join-Path $projectRoot "Scripts/Invoke-ConditionalAccessDocumentation.ps1" $caScript = Join-Path $projectRoot "Scripts/Invoke-ConditionalAccessDocumentation.ps1"
+1 -1
View File
@@ -1 +1 @@
4.1.0 4.2.0