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:
@@ -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
|
||||||
|
|||||||
@@ -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`. |
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user