From 6bf7345eb7a36bebea9cbff097350c06df88f4f4 Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Tue, 7 Jul 2026 14:57:02 +0200 Subject: [PATCH] =?UTF-8?q?release:=20v4.2.0=20=E2=80=94=20Entra=20directo?= =?UTF-8?q?ry=20role=20membership=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .gitignore | 5 + AGENTS.md | 1 + CHANGELOG_macOS_IntuneToolkit.md | 21 ++ README.md | 3 +- Scripts/Export-EntraRoleMembership.ps1 | 438 +++++++++++++++++++++++++ Start-IntuneToolkit.ps1 | 17 + VERSION | 2 +- 7 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 Scripts/Export-EntraRoleMembership.ps1 diff --git a/.gitignore b/.gitignore index fda8a63..ca333e9 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,8 @@ __pycache__/ *.pyo .venv-pdf/ .venv/ + +# Local operational artifacts that are not part of the toolkit +accounts/ +deploy.sh +restart_gateways.sh diff --git a/AGENTS.md b/AGENTS.md index 1790b91..0f54080 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. | | `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-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/Import-Policies.ps1` | Import policies from JSON. | | `Scripts/Initialize-IntuneAuth.ps1` | One-time Entra app setup; also supports `-RotateSecret`, `-Delete`, `-DeleteApp`. | diff --git a/CHANGELOG_macOS_IntuneToolkit.md b/CHANGELOG_macOS_IntuneToolkit.md index 8f616fc..c88ac92 100644 --- a/CHANGELOG_macOS_IntuneToolkit.md +++ b/CHANGELOG_macOS_IntuneToolkit.md @@ -1,5 +1,26 @@ # 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 ### Added diff --git a/README.md b/README.md index a8183c4..31cce55 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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: @@ -32,6 +32,7 @@ pwsh ./Start-IntuneToolkit.ps1 -TenantId "" * [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/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) ## Runtime diff --git a/Scripts/Export-EntraRoleMembership.ps1 b/Scripts/Export-EntraRoleMembership.ps1 new file mode 100644 index 0000000..8172a15 --- /dev/null +++ b/Scripts/Export-EntraRoleMembership.ps1 @@ -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 -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 diff --git a/Start-IntuneToolkit.ps1 b/Start-IntuneToolkit.ps1 index 4c0d44d..8a0ac4b 100644 --- a/Start-IntuneToolkit.ps1 +++ b/Start-IntuneToolkit.ps1 @@ -308,6 +308,7 @@ $commonParams = @{ } $menuItems = @( + "20. Export Entra role membership" "19. Document Conditional Access policies" "18. Rotate app secret" "17. Deploy CIS M365 baseline" @@ -514,6 +515,22 @@ while($true) 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: -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) { $caScript = Join-Path $projectRoot "Scripts/Invoke-ConditionalAccessDocumentation.ps1" diff --git a/VERSION b/VERSION index ee74734..6aba2b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.1.0 +4.2.0