Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60a655e6d4 | |||
| 81c5e96fc7 | |||
| 30170a1909 | |||
| ae71f8458d | |||
| 2f0d937ca1 | |||
| b10258c5b4 | |||
| 6bf7345eb7 | |||
| 90209a7ec4 | |||
| 122aa2d4e3 |
+8
-3
@@ -20,11 +20,11 @@ IntuneManagement.log
|
||||
Exporting */
|
||||
*.backup/
|
||||
*.backup
|
||||
Scripts/ConditionalAccessDocumentation.csv
|
||||
Scripts/ConditionalAccessDocumentation.xlsx
|
||||
|
||||
# Graph metadata cache
|
||||
# Graph metadata cache (now stored in the platform-specific data folder)
|
||||
GraphMetaData.xml
|
||||
%LOCALAPPDATA%/
|
||||
*%LOCALAPPDATA%*
|
||||
CloudAPIPowerShellManagement/
|
||||
|
||||
# Local application settings (contains secrets on non-macOS platforms)
|
||||
@@ -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
|
||||
|
||||
@@ -124,7 +124,8 @@ 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. Settings Catalog names are resolved 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/Import-Policies.ps1` | Import policies from JSON. |
|
||||
| `Scripts/Initialize-IntuneAuth.ps1` | One-time Entra app setup; also supports `-RotateSecret`, `-Delete`, `-DeleteApp`. |
|
||||
|
||||
@@ -1,5 +1,85 @@
|
||||
# macOS Intune Toolkit Changelog
|
||||
|
||||
## 2026-08-07 — v4.4.1 — Reorder TUI menu into logical groups
|
||||
|
||||
### Changed
|
||||
- **`Start-IntuneToolkit.ps1`**
|
||||
- Menu had grown by prepending each new feature to the top, so items ended up in reverse-add order with no relation between neighbors. Reordered into groups — Export/Import, Bulk operations, Baselines & compliance, Reporting, Tenant & auth admin — with Exit last. Item numbers (used for dispatch) are unchanged, only display order moved, so no other script or doc references needed updating.
|
||||
|
||||
## 2026-08-07 — v4.4.0 — Folder browser for Export/Import path prompts
|
||||
|
||||
### Added
|
||||
- **`Scripts/Private/Start-IntuneManagementTui.ps1`**
|
||||
- New `Select-FolderPath` helper: when `fzf` is available, the Export/Import root folder prompt (and the report flow's export/backup-root/output-dir prompts) becomes an interactive folder browser — navigate into subfolders, go up with `..`, confirm the current folder, or type a path manually. `Read-Host` alone has no tab-completion (that's a top-level PSReadLine feature, not available to script prompts), so this reuses the `fzf` dependency already required for menus instead of building custom completion.
|
||||
- Falls back to a plain `Read-Host` prompt when `fzf` isn't installed — no regression from prior behavior.
|
||||
|
||||
## 2026-08-07 — v4.3.1 — Fix redundant action prompt
|
||||
|
||||
### Fixed
|
||||
- **`Scripts/Private/Start-IntuneManagementTui.ps1`**
|
||||
- Choosing **1. Export policies** or **2. Import policies** in the launcher still re-asked "Select action" (Export/Import/DeployCISBaseline/GenerateReports) — the same missing-passthrough issue as the tenant re-prompt fix below, just for `-Action`. Now accepts `-Action` and skips the prompt when it's already supplied.
|
||||
- **`Start-IntuneToolkit.ps1`** / **`Scripts/Start-HeadlessIntune.ps1`**
|
||||
- Menu items 1/2 now pass `-Action Export` / `-Action Import` through to the headless script and on to the TUI.
|
||||
|
||||
## 2026-08-07 — v4.3.0 — Bulk delete tool and TUI fixes
|
||||
|
||||
### Added
|
||||
- **`Scripts/Bulk-DeletePolicies.ps1`**
|
||||
- Select object type, filter by name, multi-select objects, and delete them from the tenant.
|
||||
- Requires typing `DELETE` to confirm since the operation is irreversible. Supports `-WhatIf` dry-run.
|
||||
- Wired into `Start-IntuneToolkit.ps1` as menu item **21. Bulk delete policies**.
|
||||
|
||||
### Fixed
|
||||
- **`Start-IntuneToolkit.ps1`**
|
||||
- Fixed an infinite loop when `fzf` is not installed: the numbered-menu fallback returned `"EXIT"` for the tenant-selection Exit option, but the check only matched the fzf path's `"[Exit]"` string, so choosing Exit was silently ignored and the tenant became the literal string `"EXIT"`.
|
||||
- **`Scripts/Private/Start-IntuneManagementTui.ps1`**
|
||||
- The interactive Export/Import/GenerateReports/DeployCISBaseline flow always re-prompted for a Tenant ID even when the tenant was already selected in the outer launcher, because it was invoked with no parameters and had an empty `param()` block. It now accepts `-TenantId` and skips its three tenant prompts when the value is already supplied.
|
||||
- Clarified the "Name filter" prompt text — it's a plain case-insensitive substring match, not a regex (the underlying match escapes the value via `[RegEx]::Escape()`), so the old `'^Win-OIB-'` example was misleading.
|
||||
- **`Scripts/Start-HeadlessIntune.ps1`**
|
||||
- Passes `-TenantId` through to `Start-IntuneManagementTui.ps1` so the fix above takes effect.
|
||||
|
||||
## 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
|
||||
- **`Scripts/Export-SettingsReport.py`**
|
||||
- Greatly expanded coverage: Settings Catalog, Compliance Policies V2, Endpoint Security, Device Management Intents, Administrative Templates, Device Configuration (with OMA-URI expansion), Compliance Policies V1, PowerShell/Shell/Compliance/Health scripts (base64-decoded previews), App Protection, App Configuration, Update Policies, Enrollment restrictions, Autopilot, W365, Filters, and more.
|
||||
- Added dedicated processors for intent-style settings (`_Settings.json` companion files) and ADMX definition values.
|
||||
- Added base64 decoding for script content with a configurable preview length.
|
||||
|
||||
- **`Scripts/Invoke-ConditionalAccessDocumentation.ps1`**
|
||||
- New menu-driven Conditional Access policy documentation script (menu item **19** in `Start-IntuneToolkit.ps1`).
|
||||
- Exports CA policies to CSV and optionally Excel, resolving object IDs to display names.
|
||||
|
||||
- **`Start-IntuneToolkit.ps1`**
|
||||
- Added menu item **19. Document Conditional Access policies**.
|
||||
|
||||
### Fixed
|
||||
- **`.gitignore`**
|
||||
- Ignore `Scripts/ConditionalAccessDocumentation.csv` and `.xlsx` output files so tenant-specific documentation is not committed by accident.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-14 — Auto-export Settings Catalog definitions for report resolution
|
||||
|
||||
### Added
|
||||
@@ -9,6 +89,23 @@
|
||||
- This lets `Scripts/Export-SettingsReport.py` resolve `settingDefinitionId` values to the human-readable names shown in the Intune portal without any manual steps.
|
||||
- Errors during definition export are logged but do not fail the policy export.
|
||||
|
||||
- **`Scripts/Export-SettingsReport.py`**
|
||||
- New `Platform` column between `Policy` and `Setting`.
|
||||
- For Settings Catalog, platform is read from the `platforms` field (e.g. `macOS`, `windows10`).
|
||||
- For legacy policies, platform is inferred from `platform`/`platformType` or from `@odata.type` (e.g. `#microsoft.graph.iosCompliancePolicy` → `iOS`).
|
||||
|
||||
### Fixed
|
||||
- **`Extensions/MSGraph.psm1`**
|
||||
- `Get-GraphMetaData` now stores `GraphMetaData.xml` in the cross-platform data folder (`Get-CloudApiDataFolder`) instead of the literal Windows path `%LOCALAPPDATA%\CloudAPIPowerShellManagement\GraphMetaData.xml`.
|
||||
- Removed the stray `%LOCALAPPDATA%\CloudAPIPowerShellManagement` folder from the repository and moved the existing `GraphMetaData.xml` to the correct macOS app-data location.
|
||||
|
||||
- **`Extensions/MSALAuthentication.psm1`**
|
||||
- On non-Windows platforms the toolkit now skips `TokenCacheHelperEx` compilation with an informational log instead of throwing a `System.Security.Cryptography.ProtectedData.dll` error.
|
||||
- Applied the same skip to the legacy `Add-MSALPrereq_old` function for consistency.
|
||||
|
||||
- **`.gitignore`**
|
||||
- Removed the literal `%LOCALAPPDATA%` ignore patterns; kept `GraphMetaData.xml` and `CloudAPIPowerShellManagement/` ignores as safeguards.
|
||||
|
||||
### Modified
|
||||
- **`AGENTS.md`**
|
||||
- Added `Scripts/Export-SettingsReport.py` to the main entry points table and noted the automatic Settings Catalog name resolution.
|
||||
|
||||
@@ -554,7 +554,12 @@ function Add-MSALPrereq
|
||||
}
|
||||
}
|
||||
|
||||
if (-not ("TokenCacheHelperEx" -as [type]))
|
||||
if (-not (Test-IsWindowsPlatform))
|
||||
{
|
||||
$global:SkipTokenCacheHelperEx = $true
|
||||
Write-Log "Token cache serialization is only supported on Windows. Skipping TokenCacheHelperEx."
|
||||
}
|
||||
elseif (-not ("TokenCacheHelperEx" -as [type]))
|
||||
{
|
||||
[System.Collections.Generic.List[string]] $RequiredAssemblies = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
@@ -723,23 +728,31 @@ function Add-MSALPrereq_old
|
||||
$script:msalFile = $msalPath
|
||||
}
|
||||
|
||||
$RequiredAssemblies.Add('System.Security.dll')
|
||||
$RequiredAssemblies.Add('mscorlib.dll')
|
||||
if($PSVersionTable.PSVersion.Major -ge 7)
|
||||
{
|
||||
$RequiredAssemblies.Add('System.Security.Cryptography.ProtectedData.dll')
|
||||
}
|
||||
|
||||
$RequiredAssemblies.Add('System.Threading.dll')
|
||||
|
||||
try
|
||||
{
|
||||
Add-Type -Path ($global:AppRootFolder + "\CS\TokenCacheHelperEx.cs") -ReferencedAssemblies $RequiredAssemblies
|
||||
}
|
||||
catch
|
||||
if (-not (Test-IsWindowsPlatform))
|
||||
{
|
||||
$global:SkipTokenCacheHelperEx = $true
|
||||
Write-LogError "Failed to compile TokenCacheHelperEx. The access token will not be cached. Check write access to the CS folder and ASR policies" $_.Exception
|
||||
Write-Log "Token cache serialization is only supported on Windows. Skipping TokenCacheHelperEx."
|
||||
}
|
||||
else
|
||||
{
|
||||
$RequiredAssemblies.Add('System.Security.dll')
|
||||
$RequiredAssemblies.Add('mscorlib.dll')
|
||||
if($PSVersionTable.PSVersion.Major -ge 7)
|
||||
{
|
||||
$RequiredAssemblies.Add('System.Security.Cryptography.ProtectedData.dll')
|
||||
}
|
||||
|
||||
$RequiredAssemblies.Add('System.Threading.dll')
|
||||
|
||||
try
|
||||
{
|
||||
Add-Type -Path ($global:AppRootFolder + "\CS\TokenCacheHelperEx.cs") -ReferencedAssemblies $RequiredAssemblies
|
||||
}
|
||||
catch
|
||||
{
|
||||
$global:SkipTokenCacheHelperEx = $true
|
||||
Write-LogError "Failed to compile TokenCacheHelperEx. The access token will not be cached. Check write access to the CS folder and ASR policies" $_.Exception
|
||||
}
|
||||
}
|
||||
if(Test-IsWindowsPlatform)
|
||||
{
|
||||
|
||||
@@ -1186,7 +1186,9 @@ function Get-GraphMetaData
|
||||
# There also no other version information in response headers. Use file date to update every week
|
||||
Write-Log "Load Graph MetaData file"
|
||||
$url = "https://graph.microsoft.com/beta/`$metadata"
|
||||
$fileFullPath = [Environment]::ExpandEnvironmentVariables("%LOCALAPPDATA%\CloudAPIPowerShellManagement\GraphMetaData.xml")
|
||||
$dataFolder = if(Get-Command Get-CloudApiDataFolder -ErrorAction SilentlyContinue) { Get-CloudApiDataFolder } else { [Environment]::ExpandEnvironmentVariables("%LOCALAPPDATA%\macOS_IntuneManagement") }
|
||||
[void][IO.Directory]::CreateDirectory($dataFolder)
|
||||
$fileFullPath = Join-Path $dataFolder "GraphMetaData.xml"
|
||||
$fi = [IO.FileInfo]$fileFullPath
|
||||
$maxAge = (Get-Date).AddDays(-14)
|
||||
if($fi.Exists -and ($fi.LastWriteTime -gt $maxAge -or $fi.CreationTime -gt $maxAge))
|
||||
|
||||
@@ -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.4.1` — 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 "<tenant-id>"
|
||||
* [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
|
||||
@@ -145,6 +146,7 @@ pwsh ./Scripts/Start-HeadlessIntune.ps1 `
|
||||
* **Bulk assignments** — [`Bulk-AssignmentManager.ps1`](Scripts/Bulk-AssignmentManager.ps1) adds or removes assignments for any policy type using the bulk `/assign` endpoint. [`Bulk-AppAssignment.ps1`](Scripts/Bulk-AppAssignment.ps1) does the same for applications.
|
||||
* **Backup / restore assignments** — [`Backup-Restore-Assignments.ps1`](Scripts/Backup-Restore-Assignments.ps1) saves assignments to JSON and can restore them with cross-tenant group name resolution.
|
||||
* **Bulk rename** — [`Bulk-RenamePolicies.ps1`](Scripts/Bulk-RenamePolicies.ps1) performs search/replace or prefix mutations across policy names and descriptions.
|
||||
* **Bulk delete** — [`Bulk-DeletePolicies.ps1`](Scripts/Bulk-DeletePolicies.ps1) deletes selected policies/apps by object type and name filter, requires typing `DELETE` to confirm, supports `-WhatIf`.
|
||||
* **Device operations** — [`Bulk-DeviceOperations.ps1`](Scripts/Bulk-DeviceOperations.ps1) supports delete, retire, wipe, lock, and sync with `-WhatIf` safeguards.
|
||||
* **Assignment documentation** — [`Export-AssignmentsToCsv.ps1`](Scripts/Export-AssignmentsToCsv.ps1) exports assignments to CSV and Markdown.
|
||||
* **Reporting utilities** — [`Export-SettingsReport.py`](Scripts/Export-SettingsReport.py), [`Export-AssignmentReport.py`](Scripts/Export-AssignmentReport.py), and [`Export-ObjectInventoryReport.py`](Scripts/Export-ObjectInventoryReport.py) generate CSV/Markdown reports from local exports.
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bulk delete Intune policies/apps by object type and name filter.
|
||||
.DESCRIPTION
|
||||
Select an object type, optionally filter by name, multi-select objects,
|
||||
and delete them from the tenant. Requires typing DELETE to confirm since
|
||||
this is irreversible. Supports -WhatIf dry-run.
|
||||
.EXAMPLE
|
||||
./Scripts/Bulk-DeletePolicies.ps1 -TenantId "contoso.onmicrosoft.com" -WhatIf
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TenantId,
|
||||
|
||||
[string]$AppId,
|
||||
|
||||
[string]$Secret,
|
||||
|
||||
[string]$Certificate,
|
||||
|
||||
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
||||
[string]$AuthMode = "AppOnly",
|
||||
|
||||
[string]$RedirectUri,
|
||||
|
||||
[string]$SettingsFile,
|
||||
|
||||
[switch]$WhatIf
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
#region Helper functions
|
||||
function Test-FzfAvailable
|
||||
{
|
||||
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
function Show-FzfMenu
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one",
|
||||
[switch]$Multi
|
||||
)
|
||||
$argsList = @("--header=$Header")
|
||||
if($Multi) { $argsList += "--multi" }
|
||||
$selected = $Items | fzf @argsList
|
||||
if(-not $selected) { return $null }
|
||||
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
|
||||
return $selected
|
||||
}
|
||||
|
||||
function Show-NumberedMenu
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one or more",
|
||||
[switch]$Multi
|
||||
)
|
||||
Write-Host "`n$Header" -ForegroundColor Cyan
|
||||
for($i=0; $i -lt $Items.Count; $i++)
|
||||
{
|
||||
Write-Host " $($i+1). $($Items[$i])"
|
||||
}
|
||||
if($Multi)
|
||||
{
|
||||
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
|
||||
}
|
||||
else
|
||||
{
|
||||
$prompt = "Enter a number"
|
||||
}
|
||||
$choice = Read-Host $prompt
|
||||
if($choice -eq "all" -and $Multi) { return $Items }
|
||||
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
|
||||
if($Multi)
|
||||
{
|
||||
return $Items[$indices] | Select-Object -Unique
|
||||
}
|
||||
else
|
||||
{
|
||||
if($indices.Count -eq 0) { return $null }
|
||||
return $Items[$indices[0]]
|
||||
}
|
||||
}
|
||||
|
||||
function Select-MenuItem
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string[]]$Items,
|
||||
[string]$Header = "Select one",
|
||||
[switch]$Multi
|
||||
)
|
||||
if(Test-FzfAvailable)
|
||||
{
|
||||
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
|
||||
}
|
||||
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
|
||||
}
|
||||
|
||||
function Get-DefaultSettingsPath
|
||||
{
|
||||
if($IsWindows -or $env:OS -eq "Windows_NT")
|
||||
{
|
||||
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
|
||||
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
|
||||
}
|
||||
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
|
||||
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Initialize Runtime
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
|
||||
if(-not (Test-Path $runtimeModule))
|
||||
{
|
||||
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
|
||||
}
|
||||
|
||||
$settingsPath = $SettingsFile
|
||||
if(-not $settingsPath)
|
||||
{
|
||||
$settingsPath = Get-DefaultSettingsPath
|
||||
}
|
||||
|
||||
# Pre-load auth from settings
|
||||
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
|
||||
{
|
||||
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 { }
|
||||
}
|
||||
|
||||
$invokeParams = @{
|
||||
Silent = $true
|
||||
JSonSettings = $true
|
||||
JSonFile = $settingsPath
|
||||
TenantId = $TenantId
|
||||
AppId = $AppId
|
||||
AuthMode = $AuthMode
|
||||
}
|
||||
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
|
||||
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
|
||||
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
|
||||
|
||||
Import-Module $runtimeModule -Force
|
||||
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
|
||||
#endregion
|
||||
|
||||
#region Ensure Graph connectivity
|
||||
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
|
||||
{
|
||||
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
|
||||
}
|
||||
|
||||
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
|
||||
try
|
||||
{
|
||||
$org = Invoke-GraphRequest "/organization"
|
||||
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Object type registry (deletable types)
|
||||
$deletableTypes = @(
|
||||
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; NameProp = "name" },
|
||||
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; NameProp = "displayName" },
|
||||
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; NameProp = "displayName" }
|
||||
)
|
||||
#endregion
|
||||
|
||||
Clear-Host
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Intune Bulk Delete Tool" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
|
||||
#region Select object type
|
||||
$typeTitles = $deletableTypes | ForEach-Object { $_.Title }
|
||||
$selectedTypeTitle = Select-MenuItem -Items $typeTitles -Header "Select object type"
|
||||
if(-not $selectedTypeTitle) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
|
||||
$objectType = $deletableTypes | Where-Object { $_.Title -eq $selectedTypeTitle } | Select-Object -First 1
|
||||
#endregion
|
||||
|
||||
#region Load objects
|
||||
Write-Host "`nLoading $($objectType.Title) objects..." -ForegroundColor Cyan
|
||||
$api = "$($objectType.API)?`$select=id,$($objectType.NameProp)&`$orderby=$($objectType.NameProp)"
|
||||
$objectsResponse = Invoke-GraphRequest $api -AllPages
|
||||
$objects = $objectsResponse.value | Where-Object { $_ } | Sort-Object $objectType.NameProp
|
||||
Write-Host "Found $($objects.Count) objects." -ForegroundColor Green
|
||||
|
||||
$filter = Read-Host "`nFilter by current name (optional, press Enter to skip)"
|
||||
if(-not [string]::IsNullOrWhiteSpace($filter))
|
||||
{
|
||||
$objects = $objects | Where-Object { $_."$($objectType.NameProp)" -like "*$filter*" }
|
||||
Write-Host "Filtered to $($objects.Count) objects." -ForegroundColor Green
|
||||
}
|
||||
|
||||
if($objects.Count -eq 0)
|
||||
{
|
||||
Write-Host "No objects found. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$objectDisplays = $objects | ForEach-Object { "$($_."$($objectType.NameProp)") [$($_.id)]" }
|
||||
$selectedDisplays = Select-MenuItem -Items $objectDisplays -Header "Select objects to DELETE (multi-select)" -Multi
|
||||
if(-not $selectedDisplays)
|
||||
{
|
||||
Write-Host "No objects selected. Exiting." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$selectedObjects = @()
|
||||
foreach($disp in $selectedDisplays)
|
||||
{
|
||||
$id = $disp -replace '.*\[(.*?)\]$', '$1'
|
||||
$obj = $objects | Where-Object { $_.id -eq $id } | Select-Object -First 1
|
||||
if($obj) { $selectedObjects += $obj }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Confirm
|
||||
Write-Host "`nThe following $($selectedObjects.Count) $($objectType.Title) object(s) will be PERMANENTLY DELETED:" -ForegroundColor Red
|
||||
foreach($obj in $selectedObjects)
|
||||
{
|
||||
Write-Host " - $($obj."$($objectType.NameProp)") [$($obj.id)]" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
if(-not $WhatIf)
|
||||
{
|
||||
$typed = Read-Host "`nThis cannot be undone. Type DELETE to confirm"
|
||||
if($typed -cne "DELETE")
|
||||
{
|
||||
Write-Host "Confirmation text did not match. Cancelled." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Execute
|
||||
$success = 0
|
||||
$failed = 0
|
||||
|
||||
foreach($obj in $selectedObjects)
|
||||
{
|
||||
$name = $obj."$($objectType.NameProp)"
|
||||
try
|
||||
{
|
||||
if($WhatIf)
|
||||
{
|
||||
Write-Host " WHATIF: Would delete $name [$($obj.id)]" -ForegroundColor Magenta
|
||||
$success++
|
||||
}
|
||||
else
|
||||
{
|
||||
$maxRetries = 3
|
||||
$retryDelay = 2
|
||||
$deleted = $false
|
||||
for($r = 1; $r -le $maxRetries; $r++)
|
||||
{
|
||||
try
|
||||
{
|
||||
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)" -HttpMethod DELETE
|
||||
Write-Host " OK: Deleted '$name'" -ForegroundColor Green
|
||||
$success++
|
||||
$deleted = $true
|
||||
break
|
||||
}
|
||||
catch
|
||||
{
|
||||
$statusCode = $_.Exception.Response.StatusCode
|
||||
if($r -lt $maxRetries -and ($statusCode -ge 500 -or $statusCode -eq 429))
|
||||
{
|
||||
Write-Host " Retry $r/$maxRetries after $retryDelay`s (HTTP $statusCode)..." -ForegroundColor DarkYellow
|
||||
Start-Sleep -Seconds $retryDelay
|
||||
}
|
||||
else
|
||||
{
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
if(-not $deleted) { throw "Delete failed after $maxRetries attempts." }
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Host " ERROR: Failed to delete '$name'. $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host " Bulk Delete Complete" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Success : $success"
|
||||
Write-Host " Failed : $failed"
|
||||
#endregion
|
||||
@@ -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
|
||||
@@ -1,11 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export a flat CSV of every Intune setting/value pair from a JSON backup.
|
||||
|
||||
Covers Settings Catalog policies (human-readable names resolved from
|
||||
configurationSettings.json when present) and flat Device Configuration /
|
||||
Compliance Policy objects.
|
||||
Covers:
|
||||
- Settings Catalog + Compliance Policies V2 (settingInstance structure)
|
||||
- Endpoint Security / Device Management Intents (companion _Settings.json,
|
||||
old intent API with definitionId + value/valueJson)
|
||||
- Administrative Templates (companion _Settings.json, definitionValues)
|
||||
- Device Configuration + Compliance Policies V1 (flat, with OMA-URI expansion)
|
||||
- Scripts: PowerShell, Shell, Custom Attributes, Health Scripts
|
||||
(scriptContent / detectionScriptContent / remediationScriptContent decoded from base64)
|
||||
- App Protection, App Configuration App/Device (flat + customSettings expansion)
|
||||
- Update, Enrollment, Autopilot, W365, Filters, and other flat types
|
||||
|
||||
Output columns: Policy, Setting, Value
|
||||
Human-readable setting names resolved from configurationSettings.json when present.
|
||||
|
||||
Output columns: Policy, Platform, Setting, Value
|
||||
With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
|
||||
Group names resolved from MigrationTable.json (created by IntuneManagement export).
|
||||
"""
|
||||
@@ -13,6 +22,7 @@ With --include-assignments: adds AssignmentState, IncludeTargets, ExcludeTargets
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
@@ -20,10 +30,21 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
OUTPUT_FILE = "settings-report.csv"
|
||||
|
||||
BASE_FIELDNAMES = ["Policy", "Setting", "Value"]
|
||||
BASE_FIELDNAMES = ["Policy", "Platform", "Setting", "Value"]
|
||||
ASSIGNMENT_FIELDNAMES = ["AssignmentState", "IncludeTargets", "ExcludeTargets"]
|
||||
|
||||
_PLATFORM_LABELS = {
|
||||
"windows10": "Windows 10/11",
|
||||
"windows10X": "Windows 10X",
|
||||
"windows": "Windows",
|
||||
"macOS": "macOS",
|
||||
"iOS": "iOS",
|
||||
"android": "Android",
|
||||
"androidEnterprise": "Android Enterprise",
|
||||
"linux": "Linux",
|
||||
"chromeOS": "Chrome OS",
|
||||
}
|
||||
|
||||
_SKIP_KEYS = {
|
||||
"@odata.type", "id", "createdDateTime", "lastModifiedDateTime", "version",
|
||||
"displayName", "description", "roleScopeTagIds", "scheduledActionsForRule",
|
||||
@@ -32,8 +53,23 @@ _SKIP_KEYS = {
|
||||
"deviceManagementApplicabilityRuleOsVersion", "deviceManagementApplicabilityRuleDeviceMode",
|
||||
"supportsScopeTags", "settingCount", "priorityMetaData", "creationSource",
|
||||
"templateReference", "name", "platforms", "technologies",
|
||||
# settings arrays are handled by dedicated processors; avoid JSON blobs in flat categories
|
||||
"settings",
|
||||
}
|
||||
|
||||
# Expanded by dedicated helpers; excluded from generic flat key loop
|
||||
_SPECIAL_KEYS = {"omaSettings", "customSettings"}
|
||||
|
||||
# Keys with base64-encoded text content
|
||||
_B64_TEXT_KEYS = {"scriptContent", "detectionScriptContent", "remediationScriptContent", "payloadJson"}
|
||||
|
||||
_SCRIPT_PREVIEW_CHARS = 300
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Args
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--root", required=True,
|
||||
@@ -83,12 +119,50 @@ def _choice_label(catalog: dict[str, Any], setting_id: str, value_id: str) -> st
|
||||
return suffix.title() if suffix.islower() else suffix or value_id
|
||||
|
||||
|
||||
def _normalize_platforms(value: Any) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
items = [v.strip() for v in value.split(",")]
|
||||
elif isinstance(value, list):
|
||||
items = [str(v).strip() for v in value]
|
||||
else:
|
||||
items = [str(value).strip()]
|
||||
labels = [_PLATFORM_LABELS.get(item, item) for item in items if item]
|
||||
return "; ".join(labels)
|
||||
|
||||
|
||||
def _platform_from_odata(odata_type: str) -> str:
|
||||
lower = odata_type.lower()
|
||||
if "macos" in lower:
|
||||
return "macOS"
|
||||
if "ios" in lower:
|
||||
return "iOS"
|
||||
if "android" in lower:
|
||||
return "Android"
|
||||
if "windows" in lower:
|
||||
return "Windows"
|
||||
if "linux" in lower:
|
||||
return "Linux"
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_platform(policy: dict, category: str = "") -> str:
|
||||
platforms = policy.get("platforms")
|
||||
if platforms:
|
||||
return _normalize_platforms(platforms)
|
||||
for key in ("platform", "platformType"):
|
||||
val = policy.get(key)
|
||||
if val:
|
||||
return _normalize_platforms(val)
|
||||
return _platform_from_odata(policy.get("@odata.type", ""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assignment resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_groups(root: Path) -> dict[str, str]:
|
||||
"""Return groupId → displayName from MigrationTable.json (created by IntuneManagement export)."""
|
||||
path = root / "MigrationTable.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
@@ -104,7 +178,6 @@ def _load_groups(root: Path) -> dict[str, str]:
|
||||
|
||||
|
||||
def _resolve_target(target: dict, groups: dict[str, str]) -> tuple[str, str]:
|
||||
"""Returns (intent, display_name)."""
|
||||
ttype = target.get("@odata.type", "")
|
||||
if ttype == "#microsoft.graph.allDevicesAssignmentTarget":
|
||||
return "include", "All devices"
|
||||
@@ -151,7 +224,7 @@ def _summarize_assignments(policy: dict, groups: dict[str, str]) -> dict[str, st
|
||||
# Settings Catalog recursive walker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _walk(si: dict, catalog: dict[str, Any], policy: str,
|
||||
def _walk(si: dict, catalog: dict[str, Any], policy: str, platform: str,
|
||||
parent: str = "") -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
otype = si.get("@odata.type", "")
|
||||
@@ -161,18 +234,19 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str,
|
||||
name = f"{parent} > {name}"
|
||||
|
||||
children: list[dict] = []
|
||||
base_row = {"Policy": policy, "Platform": platform}
|
||||
|
||||
if "ChoiceSettingInstance" in otype and "Collection" not in otype:
|
||||
csv_val = si.get("choiceSettingValue", {})
|
||||
value = _choice_label(catalog, sid, csv_val.get("value", ""))
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": value})
|
||||
rows.append({**base_row, "Setting": name, "Value": value})
|
||||
children = csv_val.get("children", [])
|
||||
|
||||
elif "SimpleSettingInstance" in otype and "Collection" not in otype:
|
||||
raw = si.get("simpleSettingValue", {})
|
||||
value = str(raw.get("value", "")) if isinstance(raw, dict) else str(raw)
|
||||
if value:
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": value})
|
||||
rows.append({**base_row, "Setting": name, "Value": value})
|
||||
|
||||
elif "SimpleSettingCollectionInstance" in otype:
|
||||
vals = [
|
||||
@@ -180,14 +254,14 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str,
|
||||
for v in si.get("simpleSettingCollectionValue", [])
|
||||
]
|
||||
if vals:
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": "; ".join(vals)})
|
||||
rows.append({**base_row, "Setting": name, "Value": "; ".join(vals)})
|
||||
|
||||
elif "ChoiceSettingCollectionInstance" in otype:
|
||||
items = si.get("choiceSettingCollectionValue", [])
|
||||
vals = [_choice_label(catalog, sid, item.get("value", ""))
|
||||
for item in items if isinstance(item, dict)]
|
||||
if vals:
|
||||
rows.append({"Policy": policy, "Setting": name, "Value": "; ".join(vals)})
|
||||
rows.append({**base_row, "Setting": name, "Value": "; ".join(vals)})
|
||||
|
||||
elif "GroupSettingCollectionInstance" in otype:
|
||||
for group in si.get("groupSettingCollectionValue", []):
|
||||
@@ -195,13 +269,106 @@ def _walk(si: dict, catalog: dict[str, Any], policy: str,
|
||||
|
||||
for child in children:
|
||||
if isinstance(child, dict):
|
||||
rows.extend(_walk(child, catalog, policy, parent=name))
|
||||
rows.extend(_walk(child, catalog, policy, platform, parent=name))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Processors
|
||||
# Intent settings walker (Endpoint Security / Device Management Intents)
|
||||
# Old-style API: /deviceManagement/intents/{id}/settings
|
||||
# Each item has definitionId + value/valueJson instead of settingInstance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _intent_def_name(definition_id: str) -> str:
|
||||
"""Human name from intent definitionId like 'category--type_settingName'."""
|
||||
tail = definition_id.rsplit("_", 1)[-1]
|
||||
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ", tail).title()
|
||||
|
||||
|
||||
def _walk_intent(si: dict, policy: str, platform: str, parent: str = "") -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
defid = si.get("definitionId", "")
|
||||
name = _intent_def_name(defid)
|
||||
if parent:
|
||||
name = f"{parent} > {name}"
|
||||
|
||||
base = {"Policy": policy, "Platform": platform}
|
||||
value = si.get("value")
|
||||
value_json = si.get("valueJson", "")
|
||||
|
||||
def _emit(v: Any) -> None:
|
||||
if isinstance(v, list):
|
||||
dict_children = [c for c in v if isinstance(c, dict)]
|
||||
primitives = [c for c in v if not isinstance(c, dict)]
|
||||
for child in dict_children:
|
||||
rows.extend(_walk_intent(child, policy, platform, parent=name))
|
||||
if primitives:
|
||||
rows.append({**base, "Setting": name,
|
||||
"Value": "; ".join(str(x) for x in primitives)})
|
||||
elif v is not None:
|
||||
rows.append({**base, "Setting": name, "Value": str(v)})
|
||||
|
||||
if value is not None:
|
||||
_emit(value)
|
||||
elif value_json and value_json != "null":
|
||||
try:
|
||||
_emit(json.loads(value_json))
|
||||
except json.JSONDecodeError:
|
||||
rows.append({**base, "Setting": name, "Value": value_json})
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OMA-URI and customSettings helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _expand_oma_settings(oma_list: list, base_row: dict) -> list[dict]:
|
||||
rows = []
|
||||
for oma in oma_list:
|
||||
if not isinstance(oma, dict):
|
||||
continue
|
||||
uri = oma.get("omaUri", "")
|
||||
display = oma.get("displayName", "")
|
||||
setting_name = f"{uri} ({display})" if display else uri
|
||||
raw = oma.get("value")
|
||||
if raw is None:
|
||||
raw = oma.get("secretReferenceValueId", "")
|
||||
if isinstance(raw, (bool, int, float)):
|
||||
value_str = str(raw)
|
||||
elif isinstance(raw, str):
|
||||
value_str = raw
|
||||
else:
|
||||
value_str = json.dumps(raw, ensure_ascii=False) if raw is not None else ""
|
||||
rows.append({**base_row, "Setting": setting_name, "Value": value_str})
|
||||
return rows
|
||||
|
||||
|
||||
def _expand_custom_settings(cs_list: list, base_row: dict) -> list[dict]:
|
||||
rows = []
|
||||
for cs in cs_list:
|
||||
if not isinstance(cs, dict):
|
||||
continue
|
||||
sname = cs.get("name") or cs.get("key") or ""
|
||||
value = str(cs.get("value") or "")
|
||||
if sname:
|
||||
rows.append({**base_row, "Setting": f"customSettings/{sname}", "Value": value})
|
||||
return rows
|
||||
|
||||
|
||||
def _decode_b64_text(b64_str: str) -> str:
|
||||
try:
|
||||
text = base64.b64decode(b64_str).decode("utf-8", errors="replace").strip()
|
||||
if len(text) > _SCRIPT_PREVIEW_CHARS:
|
||||
return text[:_SCRIPT_PREVIEW_CHARS] + f"… [{len(text)} chars]"
|
||||
return text
|
||||
except Exception:
|
||||
return f"[base64 {len(b64_str)} chars]"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Folder resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
|
||||
@@ -212,23 +379,151 @@ def _resolve_folder(root: Path, *candidates: str) -> Optional[Path]:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Processors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def process_settings_catalog(root: Path, catalog: dict[str, Any],
|
||||
groups: dict[str, str],
|
||||
include_assignments: bool) -> list[dict]:
|
||||
folder = _resolve_folder(root, "SettingsCatalog", "Settings Catalog")
|
||||
"""Settings Catalog + Compliance Policies V2 — both use settings[].settingInstance."""
|
||||
folder_groups = [
|
||||
("SettingsCatalog", "Settings Catalog"),
|
||||
("CompliancePoliciesV2", "Compliance Policies - V2"),
|
||||
]
|
||||
rows: list[dict] = []
|
||||
seen: set[Path] = set()
|
||||
for candidates in folder_groups:
|
||||
folder = _resolve_folder(root, *candidates)
|
||||
if folder is None or folder in seen:
|
||||
continue
|
||||
seen.add(folder)
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
if path.stem.endswith("_Settings"):
|
||||
continue
|
||||
with path.open(encoding="utf-8") as f:
|
||||
policy = json.load(f)
|
||||
policy_name = policy.get("name") or policy.get("displayName") or path.stem
|
||||
platform = _extract_platform(policy)
|
||||
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
|
||||
for setting in policy.get("settings", []):
|
||||
si = setting.get("settingInstance", {})
|
||||
for row in _walk(si, catalog, policy_name, platform):
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def process_intent_settings(root: Path, groups: dict[str, str],
|
||||
include_assignments: bool) -> list[dict]:
|
||||
"""Endpoint Security + Device Management Intents.
|
||||
|
||||
IntuneManagement exports policy metadata to <Name>.json and settings to
|
||||
<Name>_Settings.json via the /deviceManagement/intents/{id}/settings endpoint.
|
||||
Settings use the old intent format: definitionId + value/valueJson.
|
||||
"""
|
||||
folder_groups = [
|
||||
("EndpointSecurity", "Endpoint Security"),
|
||||
("DeviceManagementIntents", "Device Management Intents"),
|
||||
]
|
||||
rows: list[dict] = []
|
||||
seen: set[Path] = set()
|
||||
for candidates in folder_groups:
|
||||
folder = _resolve_folder(root, *candidates)
|
||||
if folder is None or folder in seen:
|
||||
continue
|
||||
seen.add(folder)
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
if path.stem.endswith("_Settings"):
|
||||
continue
|
||||
with path.open(encoding="utf-8") as f:
|
||||
policy_obj = json.load(f)
|
||||
if not isinstance(policy_obj, dict):
|
||||
continue
|
||||
policy_name = policy_obj.get("displayName") or policy_obj.get("name") or path.stem
|
||||
platform = _extract_platform(policy_obj)
|
||||
assignment_cols = _summarize_assignments(policy_obj, groups) if include_assignments else {}
|
||||
|
||||
settings_path = path.parent / f"{path.stem}_Settings.json"
|
||||
settings_list: list = []
|
||||
if settings_path.is_file():
|
||||
with settings_path.open(encoding="utf-8") as f:
|
||||
sd = json.load(f)
|
||||
settings_list = sd.get("settings", sd) if isinstance(sd, dict) else sd
|
||||
|
||||
for si in settings_list:
|
||||
if isinstance(si, dict):
|
||||
for row in _walk_intent(si, policy_name, platform):
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def process_admx(root: Path, groups: dict[str, str],
|
||||
include_assignments: bool) -> list[dict]:
|
||||
"""Administrative Templates — definitionValues in companion _Settings.json.
|
||||
|
||||
IntuneManagement removes definitionValues from the main export file
|
||||
(PropertiesToRemove) and saves them separately via Start-PostExportAdministrativeTemplate.
|
||||
Each definitionValue has definition.displayName/categoryPath and presentationValues.
|
||||
"""
|
||||
folder = _resolve_folder(root, "AdministrativeTemplates", "Administrative Templates")
|
||||
rows: list[dict] = []
|
||||
if folder is None:
|
||||
return rows
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
if path.stem.endswith("_Settings"):
|
||||
continue
|
||||
with path.open(encoding="utf-8") as f:
|
||||
policy = json.load(f)
|
||||
policy_name = policy.get("name") or path.stem
|
||||
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
|
||||
for setting in policy.get("settings", []):
|
||||
si = setting.get("settingInstance", {})
|
||||
for row in _walk(si, catalog, policy_name):
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
policy_obj = json.load(f)
|
||||
if not isinstance(policy_obj, dict):
|
||||
continue
|
||||
policy_name = policy_obj.get("displayName") or policy_obj.get("name") or path.stem
|
||||
assignment_cols = _summarize_assignments(policy_obj, groups) if include_assignments else {}
|
||||
|
||||
def_values: list = policy_obj.get("definitionValues", [])
|
||||
if not def_values:
|
||||
settings_path = path.parent / f"{path.stem}_Settings.json"
|
||||
if settings_path.is_file():
|
||||
with settings_path.open(encoding="utf-8") as f:
|
||||
sd = json.load(f)
|
||||
def_values = (sd.get("definitionValues", sd)
|
||||
if isinstance(sd, dict) else sd)
|
||||
|
||||
for dv in def_values:
|
||||
if not isinstance(dv, dict):
|
||||
continue
|
||||
defn = dv.get("definition") or {}
|
||||
raw_name = defn.get("displayName") or defn.get("id", "")
|
||||
cat = defn.get("categoryPath", "").strip("\\").replace("\\", " > ")
|
||||
setting_name = f"{cat} > {raw_name}" if cat else raw_name
|
||||
|
||||
enabled = dv.get("enabled", True)
|
||||
pres_values = dv.get("presentationValues", [])
|
||||
|
||||
if not enabled:
|
||||
value_str = "Disabled"
|
||||
elif not pres_values:
|
||||
value_str = "Enabled"
|
||||
else:
|
||||
parts = []
|
||||
for pv in pres_values:
|
||||
if not isinstance(pv, dict):
|
||||
continue
|
||||
label = (pv.get("presentation") or {}).get("label") or ""
|
||||
val = pv.get("value")
|
||||
if isinstance(val, list):
|
||||
val = "; ".join(str(v) for v in val)
|
||||
else:
|
||||
val = str(val) if val is not None else ""
|
||||
parts.append(f"{label}: {val}" if label else val)
|
||||
value_str = " | ".join(parts) if parts else "Enabled"
|
||||
|
||||
row = {"Policy": policy_name, "Platform": "Windows",
|
||||
"Setting": setting_name, "Value": value_str}
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
@@ -243,22 +538,44 @@ def process_flat_category(root: Path, category: str,
|
||||
folder = folder / "Policies"
|
||||
rows: list[dict] = []
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
if path.stem.endswith("_Settings"):
|
||||
continue
|
||||
with path.open(encoding="utf-8") as f:
|
||||
policy = json.load(f)
|
||||
if not isinstance(policy, dict):
|
||||
continue
|
||||
policy_name = policy.get("displayName") or policy.get("name") or path.stem
|
||||
platform = _extract_platform(policy, category)
|
||||
assignment_cols = _summarize_assignments(policy, groups) if include_assignments else {}
|
||||
base_row = {"Policy": policy_name, "Platform": platform}
|
||||
|
||||
# OMA-URI settings (Device Configuration custom profiles)
|
||||
if isinstance(policy.get("omaSettings"), list):
|
||||
for row in _expand_oma_settings(policy["omaSettings"], base_row):
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
|
||||
# customSettings (App Configuration App, App Protection custom settings)
|
||||
if isinstance(policy.get("customSettings"), list):
|
||||
for row in _expand_custom_settings(policy["customSettings"], base_row):
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
|
||||
for key, value in policy.items():
|
||||
if key in _SKIP_KEYS or value is None:
|
||||
if key in _SKIP_KEYS or key in _SPECIAL_KEYS or value is None:
|
||||
continue
|
||||
if isinstance(value, (dict, list)):
|
||||
if key in _B64_TEXT_KEYS:
|
||||
if isinstance(value, str) and value:
|
||||
value_str = _decode_b64_text(value)
|
||||
else:
|
||||
continue
|
||||
elif isinstance(value, (dict, list)):
|
||||
value_str = json.dumps(value, ensure_ascii=False)
|
||||
if len(value_str) > 500:
|
||||
value_str = value_str[:497] + "..."
|
||||
else:
|
||||
value_str = str(value)
|
||||
row = {"Policy": policy_name, "Setting": key, "Value": value_str}
|
||||
row = {**base_row, "Setting": key, "Value": value_str}
|
||||
row.update(assignment_cols)
|
||||
rows.append(row)
|
||||
return rows
|
||||
@@ -281,17 +598,76 @@ def main() -> None:
|
||||
groups = _load_groups(root) if include_assignments else {}
|
||||
|
||||
rows: list[dict] = []
|
||||
|
||||
# --- Dedicated structured processors ---
|
||||
# Settings Catalog + Compliance Policies V2 (settingInstance)
|
||||
rows.extend(process_settings_catalog(root, catalog, groups, include_assignments))
|
||||
# Endpoint Security + Device Management Intents (companion _Settings.json, intent format)
|
||||
rows.extend(process_intent_settings(root, groups, include_assignments))
|
||||
# Administrative Templates (companion _Settings.json, definitionValues)
|
||||
rows.extend(process_admx(root, groups, include_assignments))
|
||||
|
||||
# --- Flat processors ---
|
||||
# Device Configuration (flat + OMA-URI expansion for custom profiles)
|
||||
rows.extend(process_flat_category(root, "DeviceConfiguration", groups, include_assignments,
|
||||
"Device Configuration", "Device Configurations"))
|
||||
# Compliance Policies V1
|
||||
rows.extend(process_flat_category(root, "CompliancePolicies", groups, include_assignments,
|
||||
"Compliance Policies"))
|
||||
rows.extend(process_flat_category(root, "CompliancePoliciesV2", groups, include_assignments,
|
||||
"Compliance Policies - V2"))
|
||||
rows.extend(process_flat_category(root, "EndpointSecurity", groups, include_assignments,
|
||||
"Endpoint Security"))
|
||||
rows.extend(process_flat_category(root, "AdministrativeTemplates", groups, include_assignments,
|
||||
"Administrative Templates"))
|
||||
|
||||
# Scripts (scriptContent decoded from base64)
|
||||
rows.extend(process_flat_category(root, "PowerShellScripts", groups, include_assignments,
|
||||
"Scripts (PowerShell)"))
|
||||
rows.extend(process_flat_category(root, "MacScripts", groups, include_assignments,
|
||||
"Scripts (Shell)"))
|
||||
rows.extend(process_flat_category(root, "MacCustomAttributes", groups, include_assignments,
|
||||
"Custom Attributes"))
|
||||
rows.extend(process_flat_category(root, "ComplianceScripts", groups, include_assignments,
|
||||
"Compliance Scripts"))
|
||||
rows.extend(process_flat_category(root, "DeviceHealthScripts", groups, include_assignments,
|
||||
"Health Scripts"))
|
||||
|
||||
# App (customSettings expanded; payloadJson decoded)
|
||||
rows.extend(process_flat_category(root, "AppProtection", groups, include_assignments,
|
||||
"App Protection"))
|
||||
rows.extend(process_flat_category(root, "AppConfigurationManagedApp", groups, include_assignments,
|
||||
"App Configuration (App)"))
|
||||
rows.extend(process_flat_category(root, "AppConfigurationManagedDevice", groups, include_assignments,
|
||||
"App Configuration (Device)"))
|
||||
|
||||
# Enrollment
|
||||
rows.extend(process_flat_category(root, "EnrollmentRestrictions", groups, include_assignments,
|
||||
"Enrollment Restrictions"))
|
||||
rows.extend(process_flat_category(root, "EnrollmentStatusPage", groups, include_assignments,
|
||||
"Enrollment Status Page"))
|
||||
rows.extend(process_flat_category(root, "AutoPilot", groups, include_assignments,
|
||||
"Autopilot"))
|
||||
|
||||
# Updates
|
||||
rows.extend(process_flat_category(root, "UpdatePolicies", groups, include_assignments,
|
||||
"Update Policies"))
|
||||
rows.extend(process_flat_category(root, "FeatureUpdates", groups, include_assignments,
|
||||
"Feature Updates"))
|
||||
rows.extend(process_flat_category(root, "WinFeatureUpdates", groups, include_assignments))
|
||||
rows.extend(process_flat_category(root, "QualityUpdates", groups, include_assignments,
|
||||
"Quality Updates"))
|
||||
rows.extend(process_flat_category(root, "WinQualityUpdates", groups, include_assignments))
|
||||
rows.extend(process_flat_category(root, "DriverUpdateProfiles", groups, include_assignments,
|
||||
"Driver Update Profiles"))
|
||||
rows.extend(process_flat_category(root, "WinDriverUpdatePolicies", groups, include_assignments))
|
||||
|
||||
# W365
|
||||
rows.extend(process_flat_category(root, "W365ProvisioningPolicies", groups, include_assignments,
|
||||
"W365 Provisioning Policies"))
|
||||
rows.extend(process_flat_category(root, "W365UserSettings", groups, include_assignments,
|
||||
"W365 User Settings"))
|
||||
|
||||
# Misc
|
||||
rows.extend(process_flat_category(root, "AssignmentFilters", groups, include_assignments,
|
||||
"Filters", "Assignment Filters"))
|
||||
rows.extend(process_flat_category(root, "TermsAndConditions", groups, include_assignments,
|
||||
"Terms and Conditions"))
|
||||
rows.extend(process_flat_category(root, "Notifications", groups, include_assignments))
|
||||
|
||||
for row in rows:
|
||||
for col in fieldnames:
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
<#PSScriptInfo
|
||||
|
||||
.VERSION 1.9.0
|
||||
|
||||
.GUID 6c861af7-d12e-4ea2-b5dc-56fee16e0107
|
||||
|
||||
.AUTHOR Nicola Suter
|
||||
|
||||
.TAGS ConditionalAccess, AzureAD, Identity
|
||||
|
||||
.PROJECTURI https://git.cqre.net/vibecoding/CAExporter
|
||||
|
||||
.ICONURI https://raw.githubusercontent.com/microsoftgraph/g-raph/master/g-raph.png
|
||||
|
||||
.DESCRIPTION This script documents Azure AD Conditional Access Policies using the latest Microsoft.Graph PowerShell module.
|
||||
|
||||
.SYNOPSIS This script retrieves all Conditional Access Policies and translates Azure AD Object IDs to display names for users, groups, directory roles, locations...
|
||||
|
||||
.EXAMPLE
|
||||
Connect-MgGraph -Scopes "Application.Read.All", "Group.Read.All", "Policy.Read.All", "RoleManagement.Read.Directory", "User.Read.All"
|
||||
& .\Invoke-ConditionalAccessDocumentation.ps1
|
||||
Generates the documentation and exports the csv to the script directory.
|
||||
.NOTES
|
||||
Author: Nicola Suter
|
||||
Creation Date: 31.01.2022
|
||||
Updated: 15.09.2025
|
||||
#>
|
||||
|
||||
param(
|
||||
[switch]$ExportExcel,
|
||||
[string]$ExcelPath
|
||||
)
|
||||
# NOTE: Module requirements are handled programmatically below to allow auto-install.
|
||||
$RequiredGraphVersion = '2.30.0'
|
||||
$RequiredGraphModules = @(
|
||||
'Microsoft.Graph.Authentication',
|
||||
'Microsoft.Graph.Applications',
|
||||
'Microsoft.Graph.Identity.SignIns',
|
||||
'Microsoft.Graph.Groups',
|
||||
'Microsoft.Graph.DirectoryObjects',
|
||||
'Microsoft.Graph.Identity.DirectoryManagement',
|
||||
'Microsoft.Graph.Identity.Governance'
|
||||
)
|
||||
|
||||
function Ensure-NuGetProvider {
|
||||
try {
|
||||
if (-not (Get-PackageProvider -ListAvailable -Name 'NuGet' -ErrorAction SilentlyContinue)) {
|
||||
Install-PackageProvider -Name 'NuGet' -Force -Scope CurrentUser | Out-Null
|
||||
}
|
||||
} catch { Write-Warning "NuGet provider installation failed: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
function Ensure-PSGalleryTrusted {
|
||||
try {
|
||||
$repo = Get-PSRepository -Name 'PSGallery' -ErrorAction Stop
|
||||
if ($repo.InstallationPolicy -ne 'Trusted') {
|
||||
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted -ErrorAction Stop
|
||||
}
|
||||
} catch { Write-Warning "Failed to set PSGallery trusted: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
function Ensure-Module {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Name,
|
||||
[Parameter(Mandatory)] [string] $Version
|
||||
)
|
||||
$hasVersion = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Version -eq [version]$Version }
|
||||
if (-not $hasVersion) {
|
||||
Write-Verbose "Installing $Name $Version for current user..."
|
||||
Ensure-NuGetProvider
|
||||
Ensure-PSGalleryTrusted
|
||||
try {
|
||||
Install-Module -Name $Name -RequiredVersion $Version -Scope CurrentUser -Force -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Warning "Exact version $Version not available for $Name. Installing latest available version."
|
||||
Install-Module -Name $Name -Scope CurrentUser -Force -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
Import-Module -Name $Name -ErrorAction Stop | Out-Null
|
||||
}
|
||||
|
||||
|
||||
foreach ($m in $RequiredGraphModules) { Ensure-Module -Name $m -Version $RequiredGraphVersion }
|
||||
|
||||
# If Excel export requested, ensure ImportExcel module is loaded/installed
|
||||
if ($ExportExcel) {
|
||||
try {
|
||||
Import-Module ImportExcel -ErrorAction Stop | Out-Null
|
||||
} catch {
|
||||
Write-Host 'Installing ImportExcel module for Excel export...' -ForegroundColor Cyan
|
||||
Ensure-PSGalleryTrusted
|
||||
Install-Module -Name ImportExcel -Scope CurrentUser -Force -ErrorAction Stop
|
||||
Import-Module ImportExcel -ErrorAction Stop | Out-Null
|
||||
}
|
||||
|
||||
# --- Helpers for older ImportExcel versions ---
|
||||
if (-not (Get-Command New-WorksheetName -ErrorAction SilentlyContinue)) {
|
||||
function New-WorksheetName {
|
||||
param([string]$Name)
|
||||
if (-not $Name) { return 'Sheet' }
|
||||
$san = ($Name -replace '[\\/:*?\[\]]','_')
|
||||
if ($san.Length -gt 31) { $san = $san.Substring(0,31) }
|
||||
if ($san -match '^\d+$') { $san = "_$san" }
|
||||
return $san
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Get-Command Set-Cell -ErrorAction SilentlyContinue)) {
|
||||
function Set-Cell {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Path,
|
||||
[Parameter(Mandatory)][string]$WorksheetName,
|
||||
[Parameter(Mandatory)][int]$Row,
|
||||
[Parameter(Mandatory)][int]$Column,
|
||||
[string]$Value,
|
||||
[string]$Hyperlink,
|
||||
[switch]$Formula
|
||||
)
|
||||
# Fallback using EPPlus via ImportExcel helper cmdlets
|
||||
$pkg = Open-ExcelPackage -Path $Path
|
||||
try {
|
||||
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
|
||||
if (-not $ws) { $ws = Add-WorkSheet -ExcelPackage $pkg -WorksheetName $WorksheetName }
|
||||
$cell = $ws.Cells[$Row,$Column]
|
||||
if ($Formula) {
|
||||
try { $cell.Clear() } catch { }
|
||||
if ($Value -and $Value.StartsWith('=')) { $cell.Formula = $Value.Substring(1) } else { $cell.Formula = $Value }
|
||||
} else {
|
||||
$cell.Value = $Value
|
||||
}
|
||||
if ($Hyperlink) { $cell.Hyperlink = $Hyperlink }
|
||||
}
|
||||
finally {
|
||||
try { $pkg.Workbook.CalcMode = [OfficeOpenXml.ExcelCalcMode]::Automatic } catch { }
|
||||
try { $ws.Calculate() } catch { }
|
||||
try { $pkg.Save() } catch { }
|
||||
try { $pkg.Dispose() } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Try-AddConditionalFormatting {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$WorksheetName,
|
||||
[string]$Range,
|
||||
[string]$RuleType,
|
||||
[string]$ConditionValue,
|
||||
[string]$ForegroundColor,
|
||||
[string]$BackgroundColor
|
||||
)
|
||||
try {
|
||||
Add-ConditionalFormatting -Path $Path -WorksheetName $WorksheetName -Range $Range -RuleType $RuleType -ConditionValue $ConditionValue -ForegroundColor $ForegroundColor -BackgroundColor $BackgroundColor
|
||||
} catch {
|
||||
try {
|
||||
$pkg = Open-ExcelPackage -Path $Path
|
||||
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
|
||||
if ($ws) {
|
||||
$cf = $ws.ConditionalFormatting.AddContainsText($Range)
|
||||
$cf.Text = $ConditionValue
|
||||
if ($BackgroundColor) { $cf.Style.Fill.BackgroundColor.SetColor([System.Drawing.Color]::$BackgroundColor) }
|
||||
}
|
||||
$pkg.Save(); $pkg.Dispose()
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Try-SetColumnWidth {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$WorksheetName,
|
||||
[int]$Column,
|
||||
[double]$Width
|
||||
)
|
||||
try {
|
||||
Set-Column -Path $Path -WorksheetName $WorksheetName -Column $Column -Width $Width
|
||||
} catch {
|
||||
try {
|
||||
$pkg = Open-ExcelPackage -Path $Path
|
||||
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
|
||||
if ($ws) { $ws.Column($Column).Width = $Width }
|
||||
$pkg.Save(); $pkg.Dispose()
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Try-SetFormat {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Path,
|
||||
[Parameter(Mandatory)][string]$WorksheetName,
|
||||
[Parameter(Mandatory)][string]$Range,
|
||||
[switch]$Bold,
|
||||
[int]$FontSize,
|
||||
[string]$BackgroundColor
|
||||
)
|
||||
$ok = $false
|
||||
try {
|
||||
Set-Format -Path $Path -WorksheetName $WorksheetName -Range $Range -Bold:$Bold -FontSize $FontSize -BackgroundColor $BackgroundColor
|
||||
$ok = $true
|
||||
} catch {}
|
||||
if (-not $ok) {
|
||||
try {
|
||||
Set-Format -Address $Range -WorkSheetname $WorksheetName -Bold:$Bold -FontSize $FontSize -BackgroundColor $BackgroundColor -PassThru | Export-Excel -Path $Path -WorksheetName $WorksheetName -Append
|
||||
$ok = $true
|
||||
} catch {}
|
||||
}
|
||||
if (-not $ok) {
|
||||
try {
|
||||
$pkg = Open-ExcelPackage -Path $Path
|
||||
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
|
||||
if ($ws) {
|
||||
$addr = [OfficeOpenXml.ExcelAddress]::new($Range)
|
||||
$cells = $ws.Cells[$addr.Address]
|
||||
if ($Bold) { $cells.Style.Font.Bold = $true }
|
||||
if ($FontSize -gt 0) { $cells.Style.Font.Size = $FontSize }
|
||||
if ($BackgroundColor) { $cells.Style.Fill.PatternType = [OfficeOpenXml.Style.ExcelFillStyle]::Solid; $cells.Style.Fill.BackgroundColor.SetColor([System.Drawing.Color]::$BackgroundColor) }
|
||||
}
|
||||
$pkg.Save(); $pkg.Dispose()
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Add-PolicyNamesNamedRange {
|
||||
param([Parameter(Mandatory)][string]$Path)
|
||||
$pkg = Open-ExcelPackage -Path $Path
|
||||
try {
|
||||
$ws = $pkg.Workbook.Worksheets['Master']
|
||||
if (-not $ws) { return }
|
||||
$lastCol = $ws.Dimension.End.Column
|
||||
$nameCol = $null
|
||||
for ($c=1; $c -le $lastCol; $c++) {
|
||||
if (($ws.Cells[1,$c].Text) -eq 'Name') { $nameCol = $c; break }
|
||||
}
|
||||
if ($null -eq $nameCol) { return }
|
||||
$lastRow = $ws.Dimension.End.Row
|
||||
if ($lastRow -lt 2) { return }
|
||||
$rangeAddress = [OfficeOpenXml.ExcelAddress]::new(2, $nameCol, $lastRow, $nameCol)
|
||||
$existing = $pkg.Workbook.Names['PolicyNames']
|
||||
if ($existing) { $pkg.Workbook.Names.Remove($existing) | Out-Null }
|
||||
[void]$pkg.Workbook.Names.Add('PolicyNames', $ws.Cells[$rangeAddress.Address])
|
||||
$pkg.Save()
|
||||
} finally { try { $pkg.Dispose() } catch { } }
|
||||
}
|
||||
|
||||
function Add-PolicyNameValidation {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Path,
|
||||
[Parameter(Mandatory)][string]$WorksheetName
|
||||
)
|
||||
$pkg = Open-ExcelPackage -Path $Path
|
||||
try {
|
||||
$ws = $pkg.Workbook.Worksheets[$WorksheetName]
|
||||
if (-not $ws) { return }
|
||||
$dv = $ws.DataValidations.AddListValidation('B1')
|
||||
$dv.Formula.ExcelFormula = 'PolicyNames'
|
||||
$dv.ShowErrorMessage = $true
|
||||
$dv.ErrorTitle = 'Invalid selection'
|
||||
$dv.Error = 'Please pick a policy name from the list.'
|
||||
$pkg.Save()
|
||||
} finally { try { $pkg.Dispose() } catch { } }
|
||||
}
|
||||
}
|
||||
|
||||
# --- Ensure connection to Microsoft Graph with required scopes ---
|
||||
$RequiredMgScopes = @(
|
||||
'Application.Read.All',
|
||||
'Group.Read.All',
|
||||
'Policy.Read.All',
|
||||
'RoleManagement.Read.Directory',
|
||||
'User.Read.All',
|
||||
'NetworkAccessPolicy.Read.All', # optional: for Global Secure Access profile names
|
||||
'Agreement.Read.All' # optional: for Terms of Use display names
|
||||
)
|
||||
|
||||
function Ensure-MgConnection {
|
||||
try { $ctx = Get-MgContext -ErrorAction Stop } catch { $ctx = $null }
|
||||
|
||||
$needConnect = $true
|
||||
if ($ctx) {
|
||||
$currentScopes = @($ctx.Scopes)
|
||||
if ($currentScopes -and ($RequiredMgScopes | Where-Object { $currentScopes -contains $_ }).Count -eq $RequiredMgScopes.Count) {
|
||||
$needConnect = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($needConnect) {
|
||||
Write-Host 'Connecting to Microsoft Graph...' -ForegroundColor Cyan
|
||||
Connect-MgGraph -Scopes $RequiredMgScopes -NoWelcome
|
||||
}
|
||||
}
|
||||
|
||||
Ensure-MgConnection
|
||||
|
||||
function Test-Guid {
|
||||
[Cmdletbinding()]
|
||||
[OutputType([bool])]
|
||||
param
|
||||
(
|
||||
[Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$InputObject
|
||||
)
|
||||
process {
|
||||
return [guid]::TryParse($InputObject, $([ref][guid]::Empty))
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-MgObject {
|
||||
[Cmdletbinding()]
|
||||
[OutputType([string])]
|
||||
param
|
||||
(
|
||||
[Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$InputObject
|
||||
)
|
||||
process {
|
||||
if (Test-Guid -InputObject $InputObject) {
|
||||
try {
|
||||
if ($displayNameCache.ContainsKey($InputObject)) {
|
||||
Write-Debug "Cached display name for `"$InputObject`""
|
||||
return $displayNameCache[$InputObject]
|
||||
} else {
|
||||
$directoryObject = Get-MgDirectoryObject -DirectoryObjectId $InputObject -ErrorAction Stop
|
||||
$displayName = $directoryObject.AdditionalProperties['displayName']
|
||||
$displayNameCache[$InputObject] = $displayName
|
||||
return $displayName
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Unable to resolve directory object with ID $InputObject, might have been deleted!"
|
||||
}
|
||||
}
|
||||
return $InputObject
|
||||
}
|
||||
}
|
||||
|
||||
# Add GetOrDefault to hashtables
|
||||
$etd = @{
|
||||
TypeName = 'System.Collections.Hashtable'
|
||||
MemberType = 'Scriptmethod'
|
||||
MemberName = 'GetOrDefault'
|
||||
Value = {
|
||||
param(
|
||||
$key,
|
||||
$defaultValue
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrEmpty($key)) {
|
||||
if ($this.ContainsKey($key)) {
|
||||
if ($this[$key].DisplayName) {
|
||||
return $this[$key].DisplayName
|
||||
} else {
|
||||
return $this[$key]
|
||||
}
|
||||
} else {
|
||||
return $defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Update-TypeData @etd -Force
|
||||
|
||||
Write-Progress -PercentComplete -1 -Activity 'Fetching conditional access policies and related data from Graph API'
|
||||
|
||||
try {
|
||||
if (-not (Get-MgContext)) { Write-Warning "Not connected to Microsoft Graph. Run: Connect-MgGraph -Scopes \"Application.Read.All\",\"Group.Read.All\",\"Policy.Read.All\",\"RoleManagement.Read.Directory\",\"User.Read.All\",\"NetworkAccessPolicy.Read.All\",\"Agreement.Read.All\"" }
|
||||
} catch { }
|
||||
|
||||
# Get Conditional Access Policies
|
||||
$conditionalAccessPolicies = Get-MgIdentityConditionalAccessPolicy -ExpandProperty '*' -All -ErrorAction Stop
|
||||
|
||||
# Get Conditional Access Named / Trusted Locations
|
||||
$namedLocations = Get-MgIdentityConditionalAccessNamedLocation -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
|
||||
if (-not $namedLocations) { $namedLocations = @{} }
|
||||
|
||||
# Get Azure AD Directory Role Templates
|
||||
try {
|
||||
$directoryRoleTemplates = Get-MgDirectoryRoleTemplate -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
|
||||
} catch {
|
||||
Write-Warning "Directory role templates could not be retrieved (module missing or insufficient permissions). Role names will not be resolved."
|
||||
$directoryRoleTemplates = @{}
|
||||
}
|
||||
|
||||
# Service Principals
|
||||
$servicePrincipals = Get-MgServicePrincipal -All -ErrorAction Stop | Group-Object -Property AppId -AsHashTable
|
||||
|
||||
# Terms of Use Agreements
|
||||
try {
|
||||
$termsOfUseAgreements = Get-MgIdentityGovernanceTermsOfUseAgreement -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
|
||||
} catch {
|
||||
Write-Warning "Terms of Use agreements could not be retrieved or permission missing (Agreement.Read.All)."
|
||||
$termsOfUseAgreements = @{}
|
||||
}
|
||||
|
||||
# Authentication context class references
|
||||
try {
|
||||
$authContextClassReferences = Get-MgIdentityConditionalAccessAuthenticationContextClassReference -All -ErrorAction Stop | Group-Object -Property Id -AsHashTable
|
||||
} catch {
|
||||
Write-Warning "Authentication context class references could not be retrieved. Context names will not be resolved."
|
||||
$authContextClassReferences = @{}
|
||||
}
|
||||
|
||||
# GSA network filtering profiles
|
||||
try {
|
||||
$networkFilteringProfiles = Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/beta/networkAccess/filteringProfiles' -Method GET -OutputType PSObject -ErrorAction Stop |
|
||||
Select-Object -ExpandProperty value |
|
||||
Group-Object -Property id -AsHashTable
|
||||
} catch {
|
||||
Write-Warning "Global Secure Access filtering profiles not available or insufficient permission. Skipping."
|
||||
$networkFilteringProfiles = @{}
|
||||
}
|
||||
|
||||
# Init report
|
||||
$documentation = [System.Collections.Generic.List[Object]]::new()
|
||||
# Cache for resolved display names
|
||||
$displayNameCache = @{}
|
||||
|
||||
# Process all Conditional Access Policies
|
||||
foreach ($policy in $conditionalAccessPolicies) {
|
||||
|
||||
$currentIndex = $conditionalAccessPolicies.indexOf($policy) + 1
|
||||
|
||||
$progress = @{
|
||||
Activity = 'Generating Conditional Access Documentation...'
|
||||
PercentComplete = [Decimal]::Divide($currentIndex, $conditionalAccessPolicies.Count) * 100
|
||||
CurrentOperation = "Processing Policy `"$($policy.DisplayName)`""
|
||||
}
|
||||
if ($currentIndex -eq $conditionalAccessPolicies.Count) { $progress.Add('Completed', $true) }
|
||||
|
||||
Write-Progress @progress
|
||||
|
||||
Write-Output "Processing policy `"$($policy.DisplayName)`""
|
||||
|
||||
try {
|
||||
$includeUsers = @($policy.Conditions?.Users?.IncludeUsers) | ForEach-Object { Resolve-MgObject -InputObject $_ }
|
||||
$excludeUsers = @($policy.Conditions?.Users?.ExcludeUsers) | ForEach-Object { Resolve-MgObject -InputObject $_ }
|
||||
$includeGroups = @($policy.Conditions?.Users?.IncludeGroups) | ForEach-Object { Resolve-MgObject -InputObject $_ }
|
||||
$excludeGroups = @($policy.Conditions?.Users?.ExcludeGroups) | ForEach-Object { Resolve-MgObject -InputObject $_ }
|
||||
$includeRoles = @($policy.Conditions?.Users?.IncludeRoles) | ForEach-Object { $directoryRoleTemplates.GetOrDefault($_, $_) }
|
||||
$excludeRoles = @($policy.Conditions?.Users?.ExcludeRoles) | ForEach-Object { $directoryRoleTemplates.GetOrDefault($_, $_) }
|
||||
|
||||
$includeApps = @($policy.Conditions?.Applications?.IncludeApplications) | ForEach-Object { $servicePrincipals.GetOrDefault($_, $_) }
|
||||
$excludeApps = @($policy.Conditions?.Applications?.ExcludeApplications) | ForEach-Object { $servicePrincipals.GetOrDefault($_, $_) }
|
||||
|
||||
$includeServicePrincipals = [System.Collections.Generic.List[Object]]::new()
|
||||
$excludeServicePrincipals = [System.Collections.Generic.List[Object]]::new()
|
||||
|
||||
@($policy.Conditions?.ClientApplications?.IncludeServicePrincipals) | ForEach-Object { $includeServicePrincipals.Add($servicePrincipals.GetOrDefault($_, $_)) }
|
||||
@($policy.Conditions?.ClientApplications?.ExcludeServicePrincipals) | ForEach-Object { $excludeServicePrincipals.Add($servicePrincipals.GetOrDefault($_, $_)) }
|
||||
|
||||
$includeAuthenticationContext = @($policy.Conditions?.Applications?.IncludeAuthenticationContextClassReferences) |
|
||||
ForEach-Object { $authContextClassReferences.GetOrDefault($_, $_) }
|
||||
|
||||
$includeLocations = @($policy.Conditions?.Locations?.IncludeLocations) | ForEach-Object { $namedLocations.GetOrDefault($_, $_) }
|
||||
$excludeLocations = @($policy.Conditions?.Locations?.ExcludeLocations) | ForEach-Object { $namedLocations.GetOrDefault($_, $_) }
|
||||
|
||||
$webFilteringProfile = $null
|
||||
try {
|
||||
$gsaProp = $policy.SessionControls?.AdditionalProperties?['globalSecureAccessFilteringProfile']
|
||||
if ($gsaProp) {
|
||||
$profileId = $gsaProp['profileId']
|
||||
if ($profileId -and $networkFilteringProfiles.ContainsKey($profileId)) {
|
||||
$webFilteringProfile = $networkFilteringProfiles[$profileId].name
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
$separator = "; "
|
||||
|
||||
$grantBuiltIn = @()
|
||||
if ($policy.GrantControls?.BuiltInControls) { $grantBuiltIn += $policy.GrantControls.BuiltInControls }
|
||||
if ($policy.GrantControls?.TermsOfUse) { $grantBuiltIn += 'termsOfUse' }
|
||||
if ($policy.GrantControls?.AuthenticationStrength) { $grantBuiltIn += 'authenticationStrength' }
|
||||
|
||||
$grantControls = $grantBuiltIn | Where-Object { $_ -ne 'authenticationStrength' }
|
||||
$authStrengthName = $policy.GrantControls?.AuthenticationStrength?.DisplayName
|
||||
if ($authStrengthName) { $grantControls += 'authenticationStrength' }
|
||||
|
||||
$authStrengthAllowed = @($policy.GrantControls?.AuthenticationStrength?.AllowedCombinations) -join $separator
|
||||
|
||||
$signInFrequency = $null
|
||||
if ($policy.SessionControls?.SignInFrequency?.Value) {
|
||||
$signInFrequency = "{0} {1}" -f $policy.SessionControls.SignInFrequency.Value, $policy.SessionControls.SignInFrequency.Type
|
||||
}
|
||||
|
||||
$secureSignInSession = $null
|
||||
$ss = $policy.SessionControls?.AdditionalProperties?['secureSignInSession']
|
||||
if ($ss) { $secureSignInSession = $ss.isEnabled }
|
||||
|
||||
$includeDeviceStates = @($policy.Conditions?.DeviceStates?.IncludeStates)
|
||||
$excludeDeviceStates = @($policy.Conditions?.DeviceStates?.ExcludeStates)
|
||||
|
||||
$includeGuestsOrExternalUserTypes = $policy.Conditions?.Users?.IncludeGuestsOrExternalUsers?.guestOrExternalUserTypes
|
||||
$includeGuestsOrExternalUserTenants = @($policy.Conditions?.Users?.IncludeGuestsOrExternalUsers?.externalTenants?.AdditionalProperties?['members'])
|
||||
|
||||
$authFlowsObj = $policy.Conditions?.AuthenticationFlows
|
||||
$authenticationFlows = if ($authFlowsObj) {
|
||||
$authFlowsObj.TransferMethods ?? $authFlowsObj.AdditionalProperties?['transferMethods']
|
||||
}
|
||||
|
||||
$applicationsAdditional = $null
|
||||
if ($policy.Conditions?.Applications?.AdditionalProperties) {
|
||||
$applicationsAdditional = ($policy.Conditions.Applications.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
|
||||
}
|
||||
|
||||
$conditionsAdditional = $null
|
||||
if ($policy.Conditions?.AdditionalProperties) {
|
||||
$conditionsAdditional = ($policy.Conditions.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
|
||||
}
|
||||
|
||||
$termsOfUseNames = $null
|
||||
if ($policy.GrantControls?.TermsOfUse) {
|
||||
$termsOfUseNames = ($policy.GrantControls.TermsOfUse | ForEach-Object { $termsOfUseAgreements.GetOrDefault($_, $_) }) -join $separator
|
||||
}
|
||||
|
||||
$grantControlsAdditional = $null
|
||||
if ($policy.GrantControls?.AdditionalProperties) {
|
||||
$grantControlsAdditional = ($policy.GrantControls.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
|
||||
}
|
||||
|
||||
$cloudAppSecurityMode = $policy.SessionControls?.CloudAppSecurity?.Mode
|
||||
$sessionAdditional = $null
|
||||
if ($policy.SessionControls?.AdditionalProperties) {
|
||||
$sessionAdditional = ($policy.SessionControls.AdditionalProperties | ConvertTo-Json -Depth 6 -Compress)
|
||||
}
|
||||
|
||||
$documentation.Add(
|
||||
[PSCustomObject]@{
|
||||
Name = $policy.DisplayName
|
||||
IncludeUsers = ($includeUsers -join $separator)
|
||||
IncludeGroups = ($includeGroups -join $separator)
|
||||
IncludeRoles = ($includeRoles -join $separator)
|
||||
ExcludeUsers = ($excludeUsers -join $separator)
|
||||
ExcludeGuestOrExternalUserTypes = $policy.Conditions?.Users?.ExcludeGuestsOrExternalUsers?.guestOrExternalUserTypes
|
||||
ExcludeGuestOrExternalUserTenants = (@($policy.Conditions?.Users?.ExcludeGuestsOrExternalUsers?.externalTenants?.AdditionalProperties?['members']) -join $separator)
|
||||
ExcludeGroups = ($excludeGroups -join $separator)
|
||||
ExcludeRoles = ($excludeRoles -join $separator)
|
||||
IncludeApps = ($includeApps -join $separator)
|
||||
ExcludeApps = ($excludeApps -join $separator)
|
||||
ApplicationFilterMode = $policy.Conditions?.Applications?.ApplicationFilter?.mode
|
||||
ApplicationFilterRule = $policy.Conditions?.Applications?.ApplicationFilter?.rule
|
||||
IncludeAuthenticationContext = ($includeAuthenticationContext -join $separator)
|
||||
IncludeUserActions = (@($policy.Conditions?.Applications?.IncludeUserActions) -join $separator)
|
||||
ClientAppTypes = (@($policy.Conditions?.ClientAppTypes) -join $separator)
|
||||
IncludePlatforms = (@($policy.Conditions?.Platforms?.IncludePlatforms) -join $separator)
|
||||
ExcludePlatforms = (@($policy.Conditions?.Platforms?.ExcludePlatforms) -join $separator)
|
||||
IncludeLocations = ($includeLocations -join $separator)
|
||||
ExcludeLocations = ($excludeLocations -join $separator)
|
||||
DeviceFilterMode = $policy.Conditions?.Devices?.DeviceFilter?.Mode
|
||||
DeviceFilterRule = $policy.Conditions?.Devices?.DeviceFilter?.Rule
|
||||
SignInRiskLevels = (@($policy.Conditions?.SignInRiskLevels) -join $separator)
|
||||
UserRiskLevels = (@($policy.Conditions?.UserRiskLevels) -join $separator)
|
||||
ServicePrincipalRiskLevels = (@($policy.Conditions?.servicePrincipalRiskLevels) -join $separator)
|
||||
IncludeDeviceStates = (@($includeDeviceStates) -join $separator)
|
||||
ExcludeDeviceStates = (@($excludeDeviceStates) -join $separator)
|
||||
IncludeGuestsOrExternalUserTypes = $includeGuestsOrExternalUserTypes
|
||||
IncludeGuestOrExternalUserTenants = (@($includeGuestsOrExternalUserTenants) -join $separator)
|
||||
AuthenticationFlows = $authenticationFlows
|
||||
ApplicationsAdditional = $applicationsAdditional
|
||||
ConditionsAdditional = $conditionsAdditional
|
||||
IncludeServicePrincipals = ($includeServicePrincipals -join $separator)
|
||||
ExcludeServicePrincipals = ($excludeServicePrincipals -join $separator)
|
||||
ServicePrincipalFilterMode = $policy.Conditions?.ClientApplications?.ServicePrincipalFilter?.mode
|
||||
ServicePrincipalFilter = $policy.Conditions?.ClientApplications?.ServicePrincipalFilter?.rule
|
||||
GrantControls = ($grantControls -join $separator)
|
||||
GrantControlsOperator = $policy.GrantControls?.Operator
|
||||
AuthenticationStrength = $authStrengthName
|
||||
AuthenticationStrengthAllowedCombinations = $authStrengthAllowed
|
||||
TermsOfUseNames = $termsOfUseNames
|
||||
GrantControlsAdditional = $grantControlsAdditional
|
||||
ApplicationEnforcedRestrictions = $policy.SessionControls?.ApplicationEnforcedRestrictions?.IsEnabled
|
||||
CloudAppSecurity = $policy.SessionControls?.CloudAppSecurity?.IsEnabled
|
||||
CloudAppSecurityMode = $cloudAppSecurityMode
|
||||
DisableResilienceDefaults = $policy.SessionControls?.DisableResilienceDefaults
|
||||
PersistentBrowser = $policy.SessionControls?.PersistentBrowser?.Mode
|
||||
SignInFrequency = $signInFrequency
|
||||
SecureSignInSession = $secureSignInSession
|
||||
GlobalSecureAccessFilteringProfile = $webFilteringProfile
|
||||
SessionControlsAdditional = $sessionAdditional
|
||||
State = $policy.State
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
Write-Error $PSItem
|
||||
}
|
||||
}
|
||||
|
||||
# Build export path (script directory)
|
||||
$exportPath = Join-Path $PSScriptRoot 'ConditionalAccessDocumentation.csv'
|
||||
|
||||
$CsvDelimiter = ';'
|
||||
$exportParams = @{ Path = $exportPath; NoTypeInformation = $true; Delimiter = $CsvDelimiter; Encoding = 'utf8BOM' }
|
||||
try {
|
||||
$exportParams['UseQuotes'] = 'AsNeeded'
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
$documentation | Export-Csv @exportParams
|
||||
} catch {
|
||||
Write-Warning "Export-Csv with UTF-8 BOM failed on this PowerShell version. Retrying with UTF-8 (no BOM)."
|
||||
$exportParams['Encoding'] = 'utf8'
|
||||
$documentation | Export-Csv @exportParams
|
||||
}
|
||||
|
||||
Write-Output "Exported Documentation to '$($exportPath)'"
|
||||
|
||||
if ($ExportExcel) {
|
||||
Write-Host 'Building Excel workbook...' -ForegroundColor Cyan
|
||||
if (-not $ExcelPath) {
|
||||
$ExcelPath = Join-Path $PSScriptRoot 'ConditionalAccessDocumentation.xlsx'
|
||||
}
|
||||
|
||||
if (Test-Path $ExcelPath) { Remove-Item $ExcelPath -Force }
|
||||
|
||||
$documentation | Export-Excel -Path $ExcelPath -WorksheetName 'Master' -TableName 'Master' -TableStyle 'Medium9' -ClearSheet -FreezeTopRow -AutoFilter
|
||||
|
||||
$summary = $documentation | Select-Object Name, State
|
||||
$summary | Export-Excel -Path $ExcelPath -WorksheetName 'Summary' -TableName 'Policies' -TableStyle 'Medium6' -ClearSheet -FreezeTopRow -AutoFilter
|
||||
|
||||
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName 'Summary' -Range 'B2:B1048576' -RuleType ContainsText -ConditionValue 'enabled' -ForegroundColor 'Black' -BackgroundColor 'LightGreen'
|
||||
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName 'Summary' -Range 'B2:B1048576' -RuleType ContainsText -ConditionValue 'disabled' -ForegroundColor 'Black' -BackgroundColor 'LightGray'
|
||||
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName 'Summary' -Range 'B2:B1048576' -RuleType ContainsText -ConditionValue 'reportOnly' -ForegroundColor 'Black' -BackgroundColor 'Khaki'
|
||||
|
||||
Add-PolicyNamesNamedRange -Path $ExcelPath
|
||||
|
||||
$sections = @(
|
||||
@{ Title = 'General'; Fields = @(
|
||||
@{ Label = 'Policy name'; Col = 'Name' },
|
||||
@{ Label = 'State'; Col = 'State' }
|
||||
)},
|
||||
@{ Title = 'Users and groups'; Fields = @(
|
||||
@{ Label = 'Include users'; Col = 'IncludeUsers' },
|
||||
@{ Label = 'Include groups'; Col = 'IncludeGroups' },
|
||||
@{ Label = 'Include roles'; Col = 'IncludeRoles' },
|
||||
@{ Label = 'Exclude users'; Col = 'ExcludeUsers' },
|
||||
@{ Label = 'Exclude groups'; Col = 'ExcludeGroups' },
|
||||
@{ Label = 'Exclude roles'; Col = 'ExcludeRoles' }
|
||||
)},
|
||||
@{ Title = 'Applications'; Fields = @(
|
||||
@{ Label = 'Include apps'; Col = 'IncludeApps' },
|
||||
@{ Label = 'Exclude apps'; Col = 'ExcludeApps' },
|
||||
@{ Label = 'Client app types';Col = 'ClientAppTypes' },
|
||||
@{ Label = 'AuthN context'; Col = 'IncludeAuthenticationContext' }
|
||||
)},
|
||||
@{ Title = 'Conditions'; Fields = @(
|
||||
@{ Label = 'Platforms include'; Col = 'IncludePlatforms' },
|
||||
@{ Label = 'Platforms exclude'; Col = 'ExcludePlatforms' },
|
||||
@{ Label = 'Locations include'; Col = 'IncludeLocations' },
|
||||
@{ Label = 'Locations exclude'; Col = 'ExcludeLocations' },
|
||||
@{ Label = 'Device filter mode'; Col = 'DeviceFilterMode' },
|
||||
@{ Label = 'Device filter rule'; Col = 'DeviceFilterRule' },
|
||||
@{ Label = 'Sign-in risk'; Col = 'SignInRiskLevels' },
|
||||
@{ Label = 'User risk'; Col = 'UserRiskLevels' },
|
||||
@{ Label = 'Authentication flows'; Col = 'AuthenticationFlows' }
|
||||
)},
|
||||
@{ Title = 'Grant'; Fields = @(
|
||||
@{ Label = 'Operator'; Col = 'GrantControlsOperator' },
|
||||
@{ Label = 'Controls'; Col = 'GrantControls' },
|
||||
@{ Label = 'Auth strength'; Col = 'AuthenticationStrength' },
|
||||
@{ Label = 'Allowed combos'; Col = 'AuthenticationStrengthAllowedCombinations' },
|
||||
@{ Label = 'Terms of Use'; Col = 'TermsOfUseNames' }
|
||||
)},
|
||||
@{ Title = 'Session'; Fields = @(
|
||||
@{ Label = 'App enforced restrictions'; Col = 'ApplicationEnforcedRestrictions' },
|
||||
@{ Label = 'Defender for Cloud Apps'; Col = 'CloudAppSecurity' },
|
||||
@{ Label = 'CAS mode'; Col = 'CloudAppSecurityMode' },
|
||||
@{ Label = 'Persistent browser'; Col = 'PersistentBrowser' },
|
||||
@{ Label = 'Sign-in frequency'; Col = 'SignInFrequency' },
|
||||
@{ Label = 'Secure sign-in session'; Col = 'SecureSignInSession' },
|
||||
@{ Label = 'GSA filtering profile'; Col = 'GlobalSecureAccessFilteringProfile' }
|
||||
)}
|
||||
)
|
||||
|
||||
$first = $documentation | Select-Object -First 1
|
||||
$masterHeaders = @()
|
||||
if ($first) { $masterHeaders = @($first.PSObject.Properties.Name) }
|
||||
$headerIndex = @{}
|
||||
for ($i=0; $i -lt $masterHeaders.Count; $i++) { $headerIndex[$masterHeaders[$i]] = $i+1 }
|
||||
|
||||
foreach ($item in $documentation) {
|
||||
$sheetName = New-WorksheetName -Name $item.Name
|
||||
|
||||
Export-Excel -Path $ExcelPath -WorksheetName $sheetName -ClearSheet | Out-Null
|
||||
|
||||
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row 1 -Column 1 -Value 'Policy'
|
||||
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row 1 -Column 2 -Value $item.Name
|
||||
Try-SetFormat -Path $ExcelPath -WorksheetName $sheetName -Range 'A1:B1' -Bold -FontSize 14
|
||||
|
||||
$rowPtr = 3
|
||||
foreach ($section in $sections) {
|
||||
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 1 -Value $section.Title
|
||||
Try-SetFormat -Path $ExcelPath -WorksheetName $sheetName -Range ("A$rowPtr:B$rowPtr") -Bold -BackgroundColor 'LightGray'
|
||||
$rowPtr++
|
||||
|
||||
foreach ($f in $section.Fields) {
|
||||
$label = $f.Label
|
||||
$colName = $f.Col
|
||||
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 1 -Value $label
|
||||
if ($headerIndex.ContainsKey($colName)) {
|
||||
$formula = "=INDEX(Master[$colName], MATCH(`$B`$1, Master[Name], 0))"
|
||||
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 2 -Value $formula -Formula
|
||||
} else {
|
||||
Set-Cell -Path $ExcelPath -WorksheetName $sheetName -Row $rowPtr -Column 2 -Value ''
|
||||
}
|
||||
$rowPtr++
|
||||
}
|
||||
|
||||
$rowPtr++
|
||||
}
|
||||
|
||||
Try-SetColumnWidth -Path $ExcelPath -WorksheetName $sheetName -Column 1 -Width 34
|
||||
Try-SetColumnWidth -Path $ExcelPath -WorksheetName $sheetName -Column 2 -Width 80
|
||||
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName $sheetName -Range 'B1:B200' -RuleType ContainsText -ConditionValue 'enabled' -ForegroundColor 'Black' -BackgroundColor 'LightGreen'
|
||||
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName $sheetName -Range 'B1:B200' -RuleType ContainsText -ConditionValue 'disabled' -ForegroundColor 'Black' -BackgroundColor 'LightGray'
|
||||
Try-AddConditionalFormatting -Path $ExcelPath -WorksheetName $sheetName -Range 'B1:B200' -RuleType ContainsText -ConditionValue 'reportOnly' -ForegroundColor 'Black' -BackgroundColor 'Khaki'
|
||||
}
|
||||
|
||||
# Hyperlinks from Summary -> policy sheets
|
||||
$r = 2
|
||||
foreach ($name in $documentation | Select-Object -ExpandProperty Name) {
|
||||
$ws = New-WorksheetName -Name $name
|
||||
Set-Cell -Path $ExcelPath -WorksheetName 'Summary' -Row $r -Column 1 -Value $name -Hyperlink ("#'" + $ws + "'!A1")
|
||||
$r++
|
||||
}
|
||||
|
||||
Write-Output "Exported Excel workbook to '$ExcelPath'"
|
||||
}
|
||||
@@ -8,7 +8,10 @@
|
||||
Uses fzf on macOS/Linux when available; falls back to numbered menus.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
param(
|
||||
[string]$TenantId,
|
||||
[string]$Action
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
@@ -104,6 +107,41 @@ function Select-MenuItem
|
||||
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
|
||||
}
|
||||
|
||||
function Select-FolderPath
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Prompt,
|
||||
[string]$StartPath = (Get-Location).Path
|
||||
)
|
||||
if(-not (Test-FzfAvailable))
|
||||
{
|
||||
return Read-Host $Prompt
|
||||
}
|
||||
|
||||
$current = if(Test-Path -LiteralPath $StartPath) { (Resolve-Path -LiteralPath $StartPath).Path } else { (Get-Location).Path }
|
||||
|
||||
while($true)
|
||||
{
|
||||
$entries = @("[Use this folder]", "[Type path manually]")
|
||||
$parent = Split-Path -Path $current -Parent
|
||||
if($parent) { $entries += ".." }
|
||||
$subdirs = Get-ChildItem -LiteralPath $current -Directory -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { "$($_.Name)/" }
|
||||
$entries += $subdirs
|
||||
|
||||
$choice = $entries | fzf --header="$Prompt [current: $current]"
|
||||
if(-not $choice) { return $null }
|
||||
|
||||
switch ($choice)
|
||||
{
|
||||
"[Use this folder]" { return $current }
|
||||
"[Type path manually]" { $typed = Read-Host "Enter path"; if(-not [string]::IsNullOrWhiteSpace($typed)) { return $typed }; continue }
|
||||
".." { $current = $parent; continue }
|
||||
default { $current = Join-Path $current ($choice.TrimEnd('/')); continue }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Read-YesNo
|
||||
{
|
||||
param(
|
||||
@@ -157,18 +195,33 @@ while($true)
|
||||
Write-Host " Press Esc to go back, Space to select" -ForegroundColor DarkGray
|
||||
|
||||
# 1. Action
|
||||
$action = Select-MenuItem -Items @("Export","Import","DeployCISBaseline","GenerateReports") -Header "Select action"
|
||||
if(-not $action) { continue }
|
||||
if($Action)
|
||||
{
|
||||
$action = $Action
|
||||
}
|
||||
else
|
||||
{
|
||||
$action = Select-MenuItem -Items @("Export","Import","DeployCISBaseline","GenerateReports") -Header "Select action"
|
||||
if(-not $action) { continue }
|
||||
}
|
||||
|
||||
# CIS M365 Baseline deployment flow
|
||||
if($action -eq "DeployCISBaseline")
|
||||
{
|
||||
# 2a. TenantId
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
$tenantId = Read-Host $tenantPrompt
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { $tenantId = $preloadedTenantId }
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
|
||||
if($TenantId)
|
||||
{
|
||||
$tenantId = $TenantId
|
||||
Write-Host "Tenant: $tenantId" -ForegroundColor DarkGray
|
||||
}
|
||||
else
|
||||
{
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
$tenantId = Read-Host $tenantPrompt
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { $tenantId = $preloadedTenantId }
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
|
||||
}
|
||||
|
||||
# 2b. Baseline path
|
||||
$defaultBaseline = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "Baselines/CISM365-v7-Generated.yaml"
|
||||
@@ -243,24 +296,32 @@ while($true)
|
||||
|
||||
if($dataSource -like "*fresh*")
|
||||
{
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
$tenantIdForReport = Read-Host $tenantPrompt
|
||||
if([string]::IsNullOrWhiteSpace($tenantIdForReport)) { $tenantIdForReport = $preloadedTenantId }
|
||||
if([string]::IsNullOrWhiteSpace($tenantIdForReport)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
|
||||
if($TenantId)
|
||||
{
|
||||
$tenantIdForReport = $TenantId
|
||||
Write-Host "Tenant: $tenantIdForReport" -ForegroundColor DarkGray
|
||||
}
|
||||
else
|
||||
{
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
$tenantIdForReport = Read-Host $tenantPrompt
|
||||
if([string]::IsNullOrWhiteSpace($tenantIdForReport)) { $tenantIdForReport = $preloadedTenantId }
|
||||
if([string]::IsNullOrWhiteSpace($tenantIdForReport)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
|
||||
}
|
||||
|
||||
$exportPath = Read-Host "Export path (where to save fresh data)"
|
||||
$exportPath = Select-FolderPath -Prompt "Select export path (where to save fresh data)"
|
||||
if([string]::IsNullOrWhiteSpace($exportPath)) { Write-Host "Export path is required." -ForegroundColor Red; continue }
|
||||
$backupRoot = $exportPath
|
||||
}
|
||||
else
|
||||
{
|
||||
$backupRoot = Read-Host "Backup root path (folder containing 'Settings Catalog', etc.)"
|
||||
$backupRoot = Select-FolderPath -Prompt "Select backup root (folder containing 'Settings Catalog', etc.)"
|
||||
if([string]::IsNullOrWhiteSpace($backupRoot)) { Write-Host "Backup root is required." -ForegroundColor Red; continue }
|
||||
if(-not (Test-Path $backupRoot)) { Write-Host "Path not found: $backupRoot" -ForegroundColor Red; continue }
|
||||
}
|
||||
|
||||
$outputDir = Read-Host "Enter output directory for reports"
|
||||
$outputDir = Select-FolderPath -Prompt "Select output directory for reports"
|
||||
if([string]::IsNullOrWhiteSpace($outputDir)) { Write-Host "Output directory is required." -ForegroundColor Red; continue }
|
||||
|
||||
$includeAssignmentsInSettings = $false
|
||||
@@ -312,11 +373,19 @@ while($true)
|
||||
}
|
||||
|
||||
# 2. TenantId
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
$tenantId = Read-Host $tenantPrompt
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { $tenantId = $preloadedTenantId }
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
|
||||
if($TenantId)
|
||||
{
|
||||
$tenantId = $TenantId
|
||||
Write-Host "`nTenant: $tenantId" -ForegroundColor DarkGray
|
||||
}
|
||||
else
|
||||
{
|
||||
$tenantPrompt = "Enter Tenant ID"
|
||||
if($preloadedTenantId) { $tenantPrompt += " (default: $preloadedTenantId)" }
|
||||
$tenantId = Read-Host $tenantPrompt
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { $tenantId = $preloadedTenantId }
|
||||
if([string]::IsNullOrWhiteSpace($tenantId)) { Write-Host "Tenant ID is required." -ForegroundColor Red; continue }
|
||||
}
|
||||
|
||||
# 3. Object Types
|
||||
Write-Host "`nObject type selection..." -ForegroundColor Cyan
|
||||
@@ -324,12 +393,12 @@ while($true)
|
||||
if(-not $typeSelection) { continue }
|
||||
|
||||
# 4. Path
|
||||
$pathPrompt = if($action -eq "Export") { "Enter export root folder path" } else { "Enter import root folder path" }
|
||||
$path = Read-Host $pathPrompt
|
||||
$pathPrompt = if($action -eq "Export") { "Select export root folder" } else { "Select import root folder" }
|
||||
$path = Select-FolderPath -Prompt $pathPrompt
|
||||
if([string]::IsNullOrWhiteSpace($path)) { Write-Host "Path is required." -ForegroundColor Red; return $null }
|
||||
|
||||
# 5. Name Filter
|
||||
$nameFilter = Read-Host "Name filter regex (optional, e.g. '^Win-OIB-')"
|
||||
$nameFilter = Read-Host "Name filter, text to match anywhere in name, case-insensitive (optional, e.g. 'Win-OIB-')"
|
||||
|
||||
# 6. Name Mutation
|
||||
$nameSearchPattern = Read-Host "Name search regex for mutation (optional, e.g. '^Win-OIB-')"
|
||||
|
||||
@@ -84,7 +84,7 @@ if($Interactive)
|
||||
$tuiScript = Join-Path (Split-Path -Parent $PSScriptRoot) "Scripts/Private/Start-IntuneManagementTui.ps1"
|
||||
if(Test-Path $tuiScript)
|
||||
{
|
||||
$tuiResult = & $tuiScript
|
||||
$tuiResult = & $tuiScript -TenantId $TenantId -Action $Action
|
||||
if(-not $tuiResult) { Write-Host "No selection made. Exiting." -ForegroundColor Yellow; exit 0 }
|
||||
foreach($prop in $tuiResult.PSObject.Properties)
|
||||
{
|
||||
|
||||
+64
-20
@@ -249,7 +249,7 @@ if(-not $TenantId)
|
||||
$tenantOptions += "[Exit]"
|
||||
|
||||
$selectedTenantDisplay = Select-MenuItem -Items $tenantOptions -Header "Select a tenant"
|
||||
if(-not $selectedTenantDisplay -or $selectedTenantDisplay -eq "[Exit]")
|
||||
if(-not $selectedTenantDisplay -or $selectedTenantDisplay -eq "[Exit]" -or $selectedTenantDisplay -eq "EXIT")
|
||||
{
|
||||
exit 0
|
||||
}
|
||||
@@ -308,24 +308,32 @@ $commonParams = @{
|
||||
}
|
||||
|
||||
$menuItems = @(
|
||||
"18. Rotate app secret"
|
||||
"17. Deploy CIS M365 baseline"
|
||||
"16. Generate reports"
|
||||
"15. Delete tenant auth and app registration"
|
||||
"14. Delete local tenant auth only"
|
||||
"13. Refresh tenant names"
|
||||
"12. Initialize auth (one-time setup)"
|
||||
"11. Deploy baseline (dry-run / WhatIf)"
|
||||
"10. Deploy baseline"
|
||||
"9. Bulk device operations"
|
||||
"8. Bulk rename policies"
|
||||
"7. Export assignments to CSV/Markdown"
|
||||
"6. Restore assignments"
|
||||
"5. Backup assignments"
|
||||
"4. Bulk assignment manager (policies)"
|
||||
"3. Bulk app assignment"
|
||||
"2. Import policies"
|
||||
# Export / Import
|
||||
"1. Export policies"
|
||||
"2. Import policies"
|
||||
"7. Export assignments to CSV/Markdown"
|
||||
"5. Backup assignments"
|
||||
"6. Restore assignments"
|
||||
# Bulk operations
|
||||
"3. Bulk app assignment"
|
||||
"4. Bulk assignment manager (policies)"
|
||||
"8. Bulk rename policies"
|
||||
"21. Bulk delete policies"
|
||||
"9. Bulk device operations"
|
||||
# Baselines & compliance
|
||||
"10. Deploy baseline"
|
||||
"11. Deploy baseline (dry-run / WhatIf)"
|
||||
"17. Deploy CIS M365 baseline"
|
||||
"19. Document Conditional Access policies"
|
||||
# Reporting
|
||||
"16. Generate reports"
|
||||
"20. Export Entra role membership"
|
||||
# Tenant & auth admin
|
||||
"12. Initialize auth (one-time setup)"
|
||||
"13. Refresh tenant names"
|
||||
"18. Rotate app secret"
|
||||
"14. Delete local tenant auth only"
|
||||
"15. Delete tenant auth and app registration"
|
||||
"0. Exit"
|
||||
)
|
||||
|
||||
@@ -369,6 +377,7 @@ while($true)
|
||||
6 { $script = "Scripts/Backup-Restore-Assignments.ps1" }
|
||||
7 { $script = "Scripts/Export-AssignmentsToCsv.ps1" }
|
||||
8 { $script = "Scripts/Bulk-RenamePolicies.ps1" }
|
||||
21 { $script = "Scripts/Bulk-DeletePolicies.ps1" }
|
||||
9 { $script = "Scripts/Bulk-DeviceOperations.ps1" }
|
||||
10 { $script = "Scripts/Deploy-IntuneBaseline.ps1" }
|
||||
11 { $script = "Scripts/Deploy-IntuneBaseline.ps1" }
|
||||
@@ -381,13 +390,14 @@ while($true)
|
||||
|
||||
# Clear any mode-specific params from previous loop iteration
|
||||
$commonParams.Remove("Interactive")
|
||||
$commonParams.Remove("Action")
|
||||
$commonParams.Remove("Mode")
|
||||
$commonParams.Remove("WhatIf")
|
||||
|
||||
switch($choiceNumber)
|
||||
{
|
||||
1 { $commonParams.Interactive = $true }
|
||||
2 { $commonParams.Interactive = $true }
|
||||
1 { $commonParams.Interactive = $true; $commonParams.Action = "Export" }
|
||||
2 { $commonParams.Interactive = $true; $commonParams.Action = "Import" }
|
||||
5 { $commonParams.Mode = "Backup" }
|
||||
6 { $commonParams.Mode = "Restore" }
|
||||
11 { $commonParams.WhatIf = $true }
|
||||
@@ -513,6 +523,40 @@ 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: <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)
|
||||
{
|
||||
$caScript = Join-Path $projectRoot "Scripts/Invoke-ConditionalAccessDocumentation.ps1"
|
||||
$ans = Read-Host "Export to Excel as well? [y/N]"
|
||||
$doExcel = $ans -like 'y*'
|
||||
$caParams = @{}
|
||||
if($doExcel)
|
||||
{
|
||||
$caParams.ExportExcel = $true
|
||||
$xlPath = Read-Host "Excel output path (Enter for default: Scripts/ConditionalAccessDocumentation.xlsx)"
|
||||
if(-not [string]::IsNullOrWhiteSpace($xlPath)) { $caParams.ExcelPath = $xlPath }
|
||||
}
|
||||
& $caScript @caParams
|
||||
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
|
||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
continue
|
||||
}
|
||||
|
||||
if(-not $script)
|
||||
{
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user