9 Commits

Author SHA1 Message Date
tomas.kracmar 60a655e6d4 refactor(tui): reorder main menu into logical groups
Menu had grown by prepending each new feature to the top, so items
had no relation to their neighbors. Grouped into Export/Import, Bulk
operations, Baselines & compliance, Reporting, Tenant & auth admin,
with Exit last. Item numbers (used for dispatch) unchanged, only
display order moved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 18:51:18 +02:00
tomas.kracmar 81c5e96fc7 feat(tui): add fzf-based folder browser for export/import path prompts
Read-Host has no tab-completion (that's a top-level PSReadLine feature,
not available to script prompts), so reuse the fzf dependency already
required for menus to browse folders interactively instead: navigate
into subfolders, go up with .., confirm current folder, or type a path
manually. Falls back to plain Read-Host when fzf isn't installed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 11:53:05 +02:00
tomas.kracmar 30170a1909 fix(tui): stop re-asking action when launcher already picked Export/Import
Same missing-passthrough bug as the earlier tenant re-prompt fix, this time
for -Action: Start-IntuneManagementTui.ps1 always showed the
Export/Import/DeployCISBaseline/GenerateReports picker even when the
launcher's menu items 1/2 had already decided it. Launcher now passes
-Action through, TUI skips the prompt when supplied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 11:37:35 +02:00
tomas.kracmar ae71f8458d feat(toolkit): add bulk delete tool, fix TUI exit hang and tenant re-prompt
- New Scripts/Bulk-DeletePolicies.ps1: type-DELETE confirmation, -WhatIf dry-run
- Fix infinite loop in Start-IntuneToolkit.ps1 when fzf missing (numbered-menu
  Exit returned "EXIT" but check only matched fzf's "[Exit]" string)
- Start-IntuneManagementTui.ps1 no longer re-prompts for TenantId when the
  launcher already resolved one; also fixes misleading "regex" wording on
  the Name filter prompt (it's a literal substring match)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 11:31:22 +02:00
tomas.kracmar 2f0d937ca1 fix(ca-exporter): resolve AuthenticationFlows TransferMethods and surface in Excel
Raw object dump replaced with TransferMethods extraction (falls back to
AdditionalProperties for older module versions). Field added to the
Conditions section of Excel detail sheets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 09:53:42 +02:00
tomas.kracmar b10258c5b4 fix(ca-exporter): pre-fetch auth context class references to fix ID resolution
Replaced per-policy Graph API calls with a single pre-fetch of all
authentication context class references into a hashtable, consistent
with every other lookup in the script. Also fixes silent ID drops when
DisplayName was missing — GetOrDefault now falls back to the raw ID.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 09:49:48 +02:00
tomas.kracmar 6bf7345eb7 release: v4.2.0 — Entra directory role membership export
- Add Scripts/Export-EntraRoleMembership.ps1 to export active + PIM-eligible
  Entra directory role memberships (with group expansion) to CSV.
- Wire the new script into Start-IntuneToolkit.ps1 as menu item 20.
- Document the new script in README.md and AGENTS.md.
- Bump VERSION to 4.2.0 and add CHANGELOG entry.
- Harden .gitignore against local operational artifacts (accounts/ folder,
  deploy.sh, restart_gateways.sh).
2026-07-07 14:57:02 +02:00
tomas.kracmar 90209a7ec4 feat(reporting): expand settings report and add CA documentation
- Export-SettingsReport.py: add coverage for Compliance V2, Endpoint
  Security, Device Management Intents, ADMX, scripts (base64 decoded),
  App Protection/Configuration, updates, enrollment, Autopilot, W365,
  filters, and more; add OMA-URI and customSettings expansion
- Start-IntuneToolkit.ps1: add menu item 19 for Conditional Access
  documentation
- Add Invoke-ConditionalAccessDocumentation.ps1 for CA policy docs
  (CSV/Excel)
- .gitignore: exclude CA documentation CSV/XLSX outputs
- CHANGELOG: document the new reporting capabilities
2026-06-29 15:12:37 +02:00
tomas.kracmar 122aa2d4e3 fix(reporting): add Platform column and clean up Windows artifacts
- Export-SettingsReport.py: add Platform column for Settings Catalog
  (platforms field) and legacy policies (platform/platformType or
  @odata.type inference)
- MSGraph.psm1: store GraphMetaData.xml in cross-platform data folder
  (Get-CloudApiDataFolder) instead of literal %LOCALAPPDATA% path
- MSALAuthentication.psm1: skip TokenCacheHelperEx on non-Windows with
  an info log instead of failing on missing ProtectedData.dll
- .gitignore: remove literal %LOCALAPPDATA% patterns
- AGENTS.md, CHANGELOG: document reporting and cross-platform fixes
2026-06-22 11:56:55 +02:00
14 changed files with 2230 additions and 101 deletions
+8 -3
View File
@@ -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
+2 -1
View File
@@ -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`. |
+97
View File
@@ -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.
+29 -16
View File
@@ -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)
{
+3 -1
View File
@@ -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))
+3 -1
View File
@@ -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.
+354
View File
@@ -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
+438
View File
@@ -0,0 +1,438 @@
#requires -Version 7.0
<#
.SYNOPSIS
Export all active and PIM-eligible memberships in Microsoft Entra directory
roles, including expansion of group-assigned roles to transitive group
members, and mark whether each role is privileged.
.DESCRIPTION
Uses Microsoft Graph PowerShell (Microsoft.Graph.Authentication) and the
beta roleManagement endpoints so the isPrivileged flag is available for
both built-in and custom roles. Follows this toolkit's standard
-AuthMode / -AppId / -Secret / -Certificate auth convention (see
Deploy-CISM365Baseline.ps1).
.REQUIREMENTS
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
Install-Module Microsoft.Graph.Identity.Governance -Scope CurrentUser
Install-Module Microsoft.Graph.DirectoryObjects -Scope CurrentUser
Install-Module Microsoft.Graph.Groups -Scope CurrentUser
.PERMISSIONS
Suggested Graph scopes / app roles:
RoleManagement.Read.Directory
Directory.Read.All
GroupMember.Read.All
.EXAMPLE
./Scripts/Export-EntraRoleMembership.ps1 -TenantId <tenant-id> -AuthMode Browser
.EXAMPLE
./Scripts/Export-EntraRoleMembership.ps1 -TenantId <tenant-id> -AuthMode AppOnly -AppId <app-id> -Secret <secret> -CsvPath ./reports/roles.csv
#>
[CmdletBinding()]
param(
[Parameter()]
[string]$TenantId,
[Parameter()]
[string]$AppId,
[Parameter()]
[string]$Secret,
[Parameter()]
[string]$Certificate,
[Parameter()]
[ValidateSet('AppOnly', 'Browser', 'DeviceCode')]
[string]$AuthMode = 'Browser',
[Parameter()]
[string]$SettingsFile,
[Parameter()]
[string]$CsvPath
)
$ErrorActionPreference = "Stop"
#region Helpers
function Get-ObjectType {
param([object]$Object)
if ($null -eq $Object.AdditionalProperties.'@odata.type') {
return $Object.GetType().Name
}
return ($Object.AdditionalProperties.'@odata.type' -replace '^#microsoft\.graph\.', '')
}
function Get-DisplayNameSafe {
param([object]$Object)
if ($Object.PSObject.Properties.Name -contains 'DisplayName' -and $Object.DisplayName) {
return $Object.DisplayName
}
if ($Object.AdditionalProperties -and $Object.AdditionalProperties.ContainsKey('displayName')) {
return $Object.AdditionalProperties['displayName']
}
return $null
}
function Get-UpnSafe {
param([object]$Object)
if ($Object.PSObject.Properties.Name -contains 'UserPrincipalName' -and $Object.UserPrincipalName) {
return $Object.UserPrincipalName
}
if ($Object.AdditionalProperties -and $Object.AdditionalProperties.ContainsKey('userPrincipalName')) {
return $Object.AdditionalProperties['userPrincipalName']
}
return $null
}
function Get-MailSafe {
param([object]$Object)
if ($Object.PSObject.Properties.Name -contains 'Mail' -and $Object.Mail) {
return $Object.Mail
}
if ($Object.AdditionalProperties -and $Object.AdditionalProperties.ContainsKey('mail')) {
return $Object.AdditionalProperties['mail']
}
return $null
}
function Invoke-GraphGetAllPages {
param(
[Parameter(Mandatory)]
[string]$Uri
)
$items = @()
$nextLink = $Uri
while ($nextLink) {
$response = Invoke-MgGraphRequest -Method GET -Uri $nextLink -OutputType PSObject
if ($response.value) {
$items += $response.value
}
else {
$items += $response
break
}
$nextLink = $response.'@odata.nextLink'
}
return $items
}
#endregion
#region Load saved AppId/Secret (same lookup as Invoke-IntuneHeadlessBatch)
if ($AuthMode -eq 'AppOnly' -and $TenantId -and (-not $AppId -or (-not $Secret -and -not $Certificate))) {
$coreModule = Join-Path (Split-Path -Parent $PSScriptRoot) "Core.psm1"
if (Test-Path $coreModule) {
Import-Module $coreModule -Force -Global
$settingsPath = if ($SettingsFile) { $SettingsFile } else { Join-Path (Get-CloudApiDataFolder) "Settings.json" }
if (Test-Path $settingsPath) {
try {
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
if ($settingsObj -and $settingsObj.ContainsKey($TenantId)) {
$tenantNode = $settingsObj[$TenantId]
if (-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId")) {
$AppId = $tenantNode["GraphAzureAppId"]
}
if (-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret")) {
$Secret = $tenantNode["GraphAzureAppSecret"]
}
if (-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert")) {
$Certificate = $tenantNode["GraphAzureAppCert"]
}
}
if (-not $Secret -and $IsMacOS -and $AppId) {
try {
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if ($keychainSecret) { $Secret = $keychainSecret }
}
catch { }
}
}
catch { }
}
}
}
#endregion
#region Auth
$requiredModules = @(
"Microsoft.Graph.Authentication",
"Microsoft.Graph.Identity.Governance",
"Microsoft.Graph.DirectoryObjects",
"Microsoft.Graph.Groups"
)
foreach ($mod in $requiredModules) {
if (-not (Get-Module $mod -ListAvailable)) {
throw "Module '$mod' is not installed. Run: Install-Module $mod -Scope CurrentUser"
}
Import-Module $mod -Force
}
$GraphScopes = @("RoleManagement.Read.Directory", "Directory.Read.All", "GroupMember.Read.All")
Write-Host "Connecting to Microsoft Graph (mode: $AuthMode)..." -NoNewline
$connectParams = @{}
if ($TenantId) { $connectParams.TenantId = $TenantId }
switch ($AuthMode) {
'AppOnly' {
if (-not $AppId) { throw "AppId is required for AppOnly auth mode." }
if ($Secret) {
$secureSecret = ConvertTo-SecureString -String $Secret -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($AppId, $secureSecret)
$connectParams.ClientSecretCredential = $credential
}
elseif ($Certificate) {
$cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $Certificate -or $_.Subject -eq $Certificate } | Select-Object -First 1
if (-not $cert) { throw "Certificate not found: $Certificate" }
$connectParams.ClientCertificateCredential = $cert
}
else {
throw "Secret or Certificate is required for AppOnly auth mode."
}
Connect-MgGraph @connectParams -NoWelcome
}
'DeviceCode' {
Connect-MgGraph -Scopes ($GraphScopes -join ',') @connectParams -UseDeviceCode -NoWelcome
}
default { # Browser / Interactive
Connect-MgGraph -Scopes ($GraphScopes -join ',') @connectParams -NoWelcome
}
}
$context = Get-MgContext
Write-Host " OK ($($context.Account))" -ForegroundColor Green
#endregion
Write-Host "Getting tenant information..." -ForegroundColor Cyan
$org = Invoke-GraphGetAllPages -Uri "https://graph.microsoft.com/v1.0/organization?`$select=displayName,id"
$tenantName = $org[0].displayName
$tenantId = $org[0].id
$invalidChars = [System.IO.Path]::GetInvalidFileNameChars() -join ''
$escaped = [regex]::Escape($invalidChars)
$safeTenantName = $tenantName -replace "[$escaped]", '_'
$defaultFileName = "$safeTenantName-Entra-AllRoleMemberships.csv"
if ([string]::IsNullOrWhiteSpace($CsvPath)) {
$CsvPath = ".\$defaultFileName"
}
elseif ((Test-Path -LiteralPath $CsvPath -PathType Container) -or ($CsvPath -match '[\\/]$')) {
$CsvPath = Join-Path $CsvPath $defaultFileName
}
$csvDir = Split-Path -Parent $CsvPath
if ($csvDir -and -not (Test-Path -LiteralPath $csvDir)) {
New-Item -ItemType Directory -Path $csvDir -Force | Out-Null
}
Write-Host "Tenant: $tenantName ($tenantId)" -ForegroundColor Cyan
Write-Host "Export path: $CsvPath" -ForegroundColor Cyan
Write-Host ""
Write-Host "Getting all role definitions from Graph beta endpoint..." -ForegroundColor Cyan
$roleDefsUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions"
$allRoles = Invoke-GraphGetAllPages -Uri $roleDefsUri
if (-not $allRoles) {
Write-Warning "No role definitions were returned."
return
}
Write-Host "Getting active role assignments from Graph beta endpoint..." -ForegroundColor Cyan
$assignmentsUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?`$expand=roleDefinition"
$assignments = Invoke-GraphGetAllPages -Uri $assignmentsUri
Write-Host "Getting PIM-eligible role assignments from Graph beta endpoint..." -ForegroundColor Cyan
$eligibilityUri = "https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules"
try {
$eligibleAssignments = Invoke-GraphGetAllPages -Uri $eligibilityUri
}
catch {
$errorText = "$($_.Exception.Message) $($_.ErrorDetails.Message)"
if ($errorText -match 'AadPremiumLicenseRequired') {
Write-Warning "PIM eligibility schedules require Entra ID P2 or Governance license. Skipping eligible assignments."
$eligibleAssignments = @()
}
elseif ($errorText -match 'PermissionScopeNotGranted|Authorization_RequestDenied|403') {
Write-Warning "Missing permission for PIM eligibility schedules (needs RoleManagement.Read.Directory app role granted to this app registration). Skipping eligible assignments."
$eligibleAssignments = @()
}
else {
throw
}
}
$activeWithState = foreach ($a in $assignments) { $a | Select-Object *, @{N = 'AssignmentState'; E = { 'Active' } } }
$eligibleWithState = foreach ($e in $eligibleAssignments) { $e | Select-Object *, @{N = 'AssignmentState'; E = { 'Eligible' } } }
$allAssignments = $activeWithState + $eligibleWithState
Write-Host "Discovered $($allRoles.Count) role definitions, $($assignments.Count) active assignments, and $($eligibleAssignments.Count) eligible assignments." -ForegroundColor DarkGray
$roleMap = @{}
$rolePrivilegedMap = @{}
foreach ($role in $allRoles) {
$roleMap[$role.id] = $role.displayName
$rolePrivilegedMap[$role.id] = [bool]$role.isPrivileged
}
$principalCache = @{}
$groupMemberCache = @{}
$results = New-Object System.Collections.Generic.List[object]
foreach ($assignment in $allAssignments) {
$roleId = $assignment.roleDefinitionId
$roleName = $roleMap[$roleId]
$isPrivileged = $rolePrivilegedMap[$roleId]
# Fallback to the inline expanded role definition if the role wasn't in the catalog
if (-not $roleName -and $assignment.roleDefinition) {
$roleName = $assignment.roleDefinition.displayName
$isPrivileged = [bool]$assignment.roleDefinition.isPrivileged
}
$principalId = $assignment.principalId
$directoryScopeId = $assignment.directoryScopeId
$assignmentState = $assignment.AssignmentState
# Skip assignments whose role definition could not be resolved
if (-not $roleName) {
Write-Warning "Skipping assignment for unknown role definition $roleId"
continue
}
if (-not $principalCache.ContainsKey($principalId)) {
try {
$principalCache[$principalId] = Get-MgDirectoryObjectById -Ids $principalId
}
catch {
Write-Warning "Failed to resolve principal $principalId"
continue
}
}
$principal = $principalCache[$principalId]
$principalType = Get-ObjectType -Object $principal
$principalName = Get-DisplayNameSafe -Object $principal
switch ($principalType.ToLower()) {
"group" {
if (-not $groupMemberCache.ContainsKey($principalId)) {
try {
$groupMemberCache[$principalId] = Get-MgGroupTransitiveMember -GroupId $principalId -All
}
catch {
Write-Warning "Failed to expand group members for group $principalName ($principalId)"
$groupMemberCache[$principalId] = @()
}
}
$members = $groupMemberCache[$principalId]
if (-not $members -or $members.Count -eq 0) {
$results.Add([pscustomobject]@{
IsPrivileged = $isPrivileged
AssignmentState = $assignmentState
RoleName = $roleName
RoleId = $roleId
AssignmentType = "GroupAssignment"
AssignmentPrincipalType = $principalType
AssignmentPrincipal = $principalName
AssignmentPrincipalId = $principalId
ExpandedMemberType = $null
ExpandedMemberName = $null
ExpandedMemberUPN = $null
ExpandedMemberMail = $null
ExpandedMemberId = $null
DirectoryScopeId = $directoryScopeId
Notes = "Group assigned, but no transitive members returned"
})
}
else {
foreach ($member in $members) {
$memberType = Get-ObjectType -Object $member
$memberName = Get-DisplayNameSafe -Object $member
$memberUpn = Get-UpnSafe -Object $member
$memberMail = Get-MailSafe -Object $member
$results.Add([pscustomobject]@{
IsPrivileged = $isPrivileged
AssignmentState = $assignmentState
RoleName = $roleName
RoleId = $roleId
AssignmentType = "GroupAssignmentExpanded"
AssignmentPrincipalType = $principalType
AssignmentPrincipal = $principalName
AssignmentPrincipalId = $principalId
ExpandedMemberType = $memberType
ExpandedMemberName = $memberName
ExpandedMemberUPN = $memberUpn
ExpandedMemberMail = $memberMail
ExpandedMemberId = $member.Id
DirectoryScopeId = $directoryScopeId
Notes = "Expanded from group assignment"
})
}
}
}
default {
$results.Add([pscustomobject]@{
IsPrivileged = $isPrivileged
AssignmentState = $assignmentState
RoleName = $roleName
RoleId = $roleId
AssignmentType = "DirectAssignment"
AssignmentPrincipalType = $principalType
AssignmentPrincipal = $principalName
AssignmentPrincipalId = $principalId
ExpandedMemberType = $principalType
ExpandedMemberName = $principalName
ExpandedMemberUPN = Get-UpnSafe -Object $principal
ExpandedMemberMail = Get-MailSafe -Object $principal
ExpandedMemberId = $principalId
DirectoryScopeId = $directoryScopeId
Notes = "Direct role assignment"
})
}
}
}
$results |
Sort-Object IsPrivileged, AssignmentState, RoleName, AssignmentType, ExpandedMemberName |
Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Host ""
Write-Host "Exported $($results.Count) records to: $CsvPath" -ForegroundColor Green
Write-Host ""
$results |
Sort-Object IsPrivileged, AssignmentState, RoleName, ExpandedMemberName |
Format-Table IsPrivileged, AssignmentState, RoleName, AssignmentType, AssignmentPrincipal, ExpandedMemberName, ExpandedMemberUPN, ExpandedMemberType -AutoSize
Disconnect-MgGraph | Out-Null
+409 -33
View File
@@ -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'"
}
+93 -24
View File
@@ -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-')"
+1 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
4.1.0
4.4.1