From 01366f058c1a259a3aead0e8f1dd9adb00b56809 Mon Sep 17 00:00:00 2001 From: Nathan McNulty Date: Sat, 19 Sep 2026 19:34:54 -0700 Subject: [PATCH 1/7] fix: embed EAM classification for permanent role checks --- build/Update-MtEamClassification.ps1 | 168 +++++++++++++++++ .../internal/Get-MtEamClassification.ps1 | 176 ++++++++++++++++++ .../Test-MtPrivPermanentDirectoryRole.ps1 | 21 ++- ...est-MtPrivPermanentDirectoryRole.Tests.ps1 | 66 +++++++ .../Update-MtEamClassification.Tests.ps1 | 64 +++++++ 5 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 build/Update-MtEamClassification.ps1 create mode 100644 powershell/internal/Get-MtEamClassification.ps1 create mode 100644 powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 create mode 100644 powershell/tests/functions/Update-MtEamClassification.Tests.ps1 diff --git a/build/Update-MtEamClassification.ps1 b/build/Update-MtEamClassification.ps1 new file mode 100644 index 000000000..c1dc99bf3 --- /dev/null +++ b/build/Update-MtEamClassification.ps1 @@ -0,0 +1,168 @@ +<# + .SYNOPSIS + Updates the generated Enterprise Access Model role classification table. + + .DESCRIPTION + Downloads the EntraOps directory-role classification at build time, projects + it to the role ID and EAM tier used by Maester, validates the result, and + writes the generated table into the module's internal source. The module does + not fetch this third-party data during a test run. + + .EXAMPLE + ./build/Update-MtEamClassification.ps1 +#> + +[CmdletBinding()] +param ( + # Path to the generated classification source file. + [string] $ClassificationPath = "$PSScriptRoot/../powershell/internal/Get-MtEamClassification.ps1", + + # Source URL used only when updating the checked-in table. + [string] $SourceUrl = 'https://raw.githubusercontent.com/Cloud-Architekt/AzurePrivilegedIAM/main/Classification/Classification_EntraIdDirectoryRoles.json', + + # Minimum expected row count, guarding against a partial or unrelated response. + [int] $MinimumRoleCount = 100 +) + +$ErrorActionPreference = 'Stop' + +function Get-EamClassificationData { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Json, + + [Parameter(Mandatory)] + [int] $MinimumRoleCount + ) + + $rows = @($Json | ConvertFrom-Json -Depth 10) + if ($rows.Count -lt $MinimumRoleCount) { + throw "Only $($rows.Count) EAM role classifications found; expected at least $MinimumRoleCount. Possible parsing issue." + } + + $validTiers = @('ControlPlane', 'ManagementPlane', 'UserAccess', 'Unclassified') + $seenRoleIds = @{} + $classification = [System.Collections.Generic.List[hashtable]]::new() + + foreach ($row in $rows) { + $roleId = ([string]$row.RoleId).Trim().ToLowerInvariant() + $tier = ([string]$row.Classification.EAMTierLevelName).Trim() + + $roleGuid = [guid]::Empty + if (-not [guid]::TryParse($roleId, [ref]$roleGuid)) { + throw "Role classification contains an invalid RoleId '$roleId'." + } + if ([string]::IsNullOrWhiteSpace($tier) -or $tier -notin $validTiers) { + throw "Role '$roleId' contains an unknown or empty EAM tier '$tier'." + } + if ($seenRoleIds.ContainsKey($roleId)) { + throw "Role classification contains duplicate RoleId '$roleId'." + } + + $seenRoleIds[$roleId] = $true + $classification.Add(@{ + RoleId = $roleId + Tier = $tier + }) + } + + return $classification +} + +function Get-ClassificationFileContent { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Collections.Generic.List[hashtable]] $Classification, + + [Parameter(Mandatory)] + [string] $SourceUrl, + + [Parameter(Mandatory)] + [string] $SourceSha256 + ) + + $entries = $Classification | Sort-Object RoleId | ForEach-Object { + " '$($_.RoleId)' = '$($_.Tier)'" + } + $entryBlock = $entries -join "`n" + + return @" +# Auto-generated by build/Update-MtEamClassification.ps1. Do not edit manually. +# Source: $SourceUrl +# Source SHA-256: $SourceSha256 +# Source rows: $($Classification.Count) + +function Initialize-MtEamClassification { + [CmdletBinding()] + param() + + if (`$null -ne `$script:MtEamClassification) { + return + } + + `$script:MtEamClassification = @{ + # BEGIN AUTO-GENERATED EAM CLASSIFICATION +$entryBlock + # END AUTO-GENERATED EAM CLASSIFICATION + } +} + +function Get-MtEamClassification { + <# + .SYNOPSIS + Returns the checked-in EntraOps Enterprise Access Model classification table. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param() + + Initialize-MtEamClassification + return `$script:MtEamClassification +} +"@ +} + +Write-Host 'Fetching EAM role classifications from GitHub...' -ForegroundColor Cyan +try { + $response = Invoke-WebRequest -Uri $SourceUrl -UseBasicParsing -ErrorAction Stop +} catch { + throw "Failed to download EAM role classifications from $SourceUrl. Error: $_" +} + +if ($response.StatusCode -ne 200) { + throw "Unexpected HTTP status code $($response.StatusCode) from $SourceUrl" +} +if ([string]::IsNullOrWhiteSpace($response.Content) -or $response.Content.Length -lt 10000) { + throw "Downloaded EAM classification appears empty or too short ($($response.Content.Length) chars). Aborting." +} + +$classification = Get-EamClassificationData -Json $response.Content -MinimumRoleCount $MinimumRoleCount +$sourceBytes = [System.Text.Encoding]::UTF8.GetBytes($response.Content) +$sha256 = [System.Security.Cryptography.SHA256]::Create() +try { + $sourceHash = (-join ($sha256.ComputeHash($sourceBytes) | ForEach-Object { $_.ToString('x2') })) +} finally { + $sha256.Dispose() +} +$generatedContent = Get-ClassificationFileContent -Classification $classification -SourceUrl $SourceUrl -SourceSha256 $sourceHash + +$resolvedPath = $ClassificationPath +if (Test-Path -LiteralPath $ClassificationPath) { + $resolvedPath = (Resolve-Path -LiteralPath $ClassificationPath).ProviderPath +} else { + $parent = Split-Path -Parent $ClassificationPath + if (-not (Test-Path -LiteralPath $parent)) { + throw "Classification output directory '$parent' was not found." + } + $resolvedPath = [System.IO.Path]::GetFullPath($ClassificationPath) +} + +$utf8Bom = [System.Text.UTF8Encoding]::new($true) +[System.IO.File]::WriteAllText($resolvedPath, $generatedContent.TrimEnd() + "`n", $utf8Bom) + +Write-Host 'Update complete!' -ForegroundColor Green +Write-Host " Roles: $($classification.Count)" +Write-Host " Source hash: $sourceHash" +Write-Host " Updated: $resolvedPath" diff --git a/powershell/internal/Get-MtEamClassification.ps1 b/powershell/internal/Get-MtEamClassification.ps1 new file mode 100644 index 000000000..b30349883 --- /dev/null +++ b/powershell/internal/Get-MtEamClassification.ps1 @@ -0,0 +1,176 @@ +# Auto-generated by build/Update-MtEamClassification.ps1. Do not edit manually. +# Source: https://raw.githubusercontent.com/Cloud-Architekt/AzurePrivilegedIAM/main/Classification/Classification_EntraIdDirectoryRoles.json +# Source SHA-256: 36de9d95f6ed1949684667de4a05b3e3859d2ed9af41c77dc0fda0fffc08b672 +# Source rows: 145 + +function Initialize-MtEamClassification { + [CmdletBinding()] + param() + + if ($null -ne $script:MtEamClassification) { + return + } + + $script:MtEamClassification = @{ + # BEGIN AUTO-GENERATED EAM CLASSIFICATION + '024906de-61e5-49c8-8572-40335f1e0e10' = 'ManagementPlane' + '02d5655b-c1cf-4e5f-98da-5fb919085bf6' = 'ManagementPlane' + '0526716b-113d-4c15-b2c8-68e3c22b9f80' = 'ControlPlane' + '0964bb5e-9bdb-4d7b-ac29-58e794862a40' = 'ManagementPlane' + '0b00bede-4072-4d22-b441-e7df02a1ef63' = 'ControlPlane' + '0ec3f692-38d6-4d14-9e69-0377ca7797ad' = 'ManagementPlane' + '0f971eea-41eb-4569-a71e-57bb8a3eff1e' = 'ControlPlane' + '1076ac91-f3d9-41a7-a339-dcdf5f480acc' = 'ManagementPlane' + '10dae51f-b6af-4016-8d66-8c2a99b929b3' = 'UserAccess' + '112ca1a2-15ad-4102-995e-45b0bc479a6a' = 'UserAccess' + '11451d60-acb2-45eb-a7d6-43d0f0125c13' = 'ControlPlane' + '11648597-926c-4cf3-9c36-bcebb0ba8dcc' = 'ManagementPlane' + '124577f8-48ed-456a-839f-13b419002e33' = 'ManagementPlane' + '1501b917-7653-4ff9-a4b5-203eaf33784f' = 'ManagementPlane' + '158c047a-c907-4556-b7ef-446551a6b5f7' = 'ControlPlane' + '1707125e-0aa2-4d4d-8655-a7c786c76a25' = 'ManagementPlane' + '17315797-102d-40b4-93e0-432062caca18' = 'ManagementPlane' + '194ae4cb-b126-40b2-bd5b-6091b380977d' = 'ControlPlane' + '1981f584-96e9-4a6f-95b0-f522373f8fae' = 'ControlPlane' + '1a7d78b6-429f-476b-b8eb-35fb715fffd4' = 'ManagementPlane' + '1d336d2c-4ae8-42ef-9711-b3604ce3fc2c' = 'ControlPlane' + '1fe13547-53f6-408d-ac04-7f8eed167b38' = 'ControlPlane' + '25a516ed-2fa0-40ea-a2d0-12923a21473a' = 'ControlPlane' + '25df335f-86eb-4119-b717-0ff02de207e9' = 'ManagementPlane' + '27460883-1df1-4691-b032-3b79643e5e63' = 'ManagementPlane' + '281fe777-fb20-4fbb-b7a3-ccebce5b0d96' = 'ManagementPlane' + '29232cdf-9323-42fd-ade2-1d097af3e4de' = 'ManagementPlane' + '2af84b1e-32c8-42b7-82bc-daa82404023b' = 'UserAccess' + '2b499bcd-da44-4968-8aec-78e1674fa64d' = 'ControlPlane' + '2b745bdf-0803-4d80-aa65-822c4493daac' = 'ManagementPlane' + '2ea5ce4c-b2d8-4668-bd81-3680bd2d227a' = 'ControlPlane' + '2fe872fb-daa8-4afc-8f6c-53c4565cfef4' = 'ManagementPlane' + '31392ffb-586c-42d1-9346-e59415a2cc4e' = 'ManagementPlane' + '31e939ad-9672-4796-9c2e-873181342d2d' = 'ManagementPlane' + '32696413-001a-46ae-978c-ce0f6b3620d2' = 'ControlPlane' + '38a96431-2bdf-4b4c-8b6e-5d3d8abac1a4' = 'ManagementPlane' + '3a2c62db-5318-420d-8d74-23affee5d9d5' = 'ControlPlane' + '3d762c5a-1b6c-493f-843e-55a3b42923d4' = 'ManagementPlane' + '3edaf663-341e-4475-9f94-5c398ef6c070' = 'ControlPlane' + '3f04f91a-4ad7-4bd3-bcfa-49882ea1a88a' = 'ManagementPlane' + '3f1acade-1e04-4fbc-9b69-f0302cd84aef' = 'ManagementPlane' + '422218e4-db15-4ef9-bbe0-8afb41546d79' = 'ControlPlane' + '44367163-eba1-44c3-98af-f5787879f96a' = 'ManagementPlane' + '45d8d3c5-c802-45c6-b32a-1d70b5e1e86e' = 'ControlPlane' + '49eb8f75-97e9-4e37-9b2b-6c3ebfcffa31' = 'ManagementPlane' + '4a5d8f65-41da-4de4-8968-e035b65339cf' = 'ManagementPlane' + '4ba39ca4-527c-499a-b93d-d9b492c50246' = 'ControlPlane' + '4d6ac14f-3453-41d0-bef9-a3e0c569773a' = 'ControlPlane' + '507f53e4-4e52-4077-abd3-d2e1558b6ea2' = 'ManagementPlane' + '58a13ea3-c632-46ae-9ee0-9c0d43cd7f3d' = 'ControlPlane' + '58f930cc-fcf4-4152-852c-1d7dbf502139' = 'ControlPlane' + '59d46f88-662b-457b-bceb-5c3809e5908f' = 'ControlPlane' + '5b784334-f94b-471a-a387-e7219fc49ca2' = 'ControlPlane' + '5c4f9dcd-47dc-4cf7-8c9a-9e4207cbfc91' = 'ManagementPlane' + '5d6b6bb7-de71-4623-b4af-96380a352509' = 'ControlPlane' + '5f2222b1-57c3-48ba-8ad5-d4759f1fde6f' = 'ControlPlane' + '62e90394-69f5-4237-9190-012177145e10' = 'ControlPlane' + '644ef478-e28f-4e28-b9dc-3fdde9aa0b1f' = 'ManagementPlane' + '69091246-20e8-4a56-aa4d-066075b2a7a8' = 'ManagementPlane' + '6b942400-691f-4bf0-9d12-d8a254a2baf5' = 'ControlPlane' + '6e591065-9bad-43ed-90f3-e9424366d2f0' = 'ControlPlane' + '729827e3-9c14-49f7-bb1b-9608f156bbb8' = 'ControlPlane' + '744ec460-397e-42ad-a462-8b3f9747a02c' = 'ControlPlane' + '7495fdc4-34c4-4d15-a289-98788ce399fd' = 'ManagementPlane' + '74ef975b-6605-40af-a5d2-b9539d836353' = 'ManagementPlane' + '75934031-6c7e-415a-99d7-48dbd49e875e' = 'ManagementPlane' + '75941009-915a-4869-abe7-691bff18279e' = 'ManagementPlane' + '7698a772-787b-4ac8-901f-60d6b08affd2' = 'ControlPlane' + '78b0ccd1-afc2-4f92-9116-b41aedd09592' = 'ManagementPlane' + '790c1fb9-7f7d-4f88-86a1-ef1f95c05c1b' = 'ManagementPlane' + '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' = 'ControlPlane' + '810a2642-a034-447f-a5e8-41beaa378541' = 'ManagementPlane' + '8329153b-31d0-4727-b945-745eb3bc5f31' = 'ControlPlane' + '8424c6f0-a189-499e-bbd0-26c1753c96d4' = 'ControlPlane' + '843318fb-79a6-4168-9e6f-aa9a07481cc4' = 'ManagementPlane' + '87761b17-1ed2-4af3-9acd-92a150038160' = 'ManagementPlane' + '8835291a-918c-4fd7-a9ce-faa49f0cf7d9' = 'ManagementPlane' + '88d8e3e3-8f55-4a1e-953a-9b9898b8876b' = 'ManagementPlane' + '892c5842-a9a6-463a-8041-72aa08ca3cf6' = 'ControlPlane' + '8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2' = 'ControlPlane' + '8c8b803f-96e1-4129-9349-20738d9f9652' = 'ManagementPlane' + '92b086b3-e367-4ef2-b869-1de128fb986e' = 'ManagementPlane' + '92ed04bf-c94a-4b82-9729-b799a7a4c178' = 'ControlPlane' + '9360feb5-f418-4baa-8175-e2a00bac4301' = 'ControlPlane' + '95e79109-95c0-4d8e-aee3-d01accf2d47b' = 'UserAccess' + '963797fb-eb3b-4cde-8ce3-5878b3f32a3f' = 'ManagementPlane' + '966707d0-3269-4727-9be2-8c3a10f19b9d' = 'ControlPlane' + '99009c4a-3b3f-4957-82a9-9d35e12db77e' = 'ManagementPlane' + '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3' = 'ControlPlane' + '9c094953-4995-41c8-84c8-3ebb9b32c93f' = 'Unclassified' + '9c6df0f2-1e7c-4dc3-b195-66dfbd24aa8f' = 'ManagementPlane' + '9c99539d-8186-4804-835f-fd51ef9e2dcd' = 'ManagementPlane' + '9d3e04ba-3ee4-4d1b-a3a7-9aef423a09be' = 'ManagementPlane' + '9d70768a-0cbc-4b4c-aea3-2e124b2477f4' = 'ManagementPlane' + '9f06204d-73c1-4d4c-880a-6edb90606fd8' = 'ControlPlane' + 'a0b1b346-4d3e-4e8b-98f8-753987be4970' = 'UserAccess' + 'a92aed5d-d78a-4d16-b381-09adb37eb3b0' = 'ControlPlane' + 'a9ea8996-122f-4c74-9520-8edcd192826c' = 'ManagementPlane' + 'aa38014f-0993-46e9-9b45-30501a20909d' = 'ManagementPlane' + 'aaf43236-0c0d-4d5f-883a-6955382ac081' = 'ControlPlane' + 'ac16e43d-7b2d-40e0-ac05-243ff356ab5b' = 'ManagementPlane' + 'ac434307-12b9-4fa1-a708-88bf58caabc1' = 'ControlPlane' + 'adb2368d-a9be-41b5-8667-d96778e081b0' = 'ControlPlane' + 'af78dc32-cf4d-46f9-ba4e-4428526346b5' = 'ControlPlane' + 'b0f54661-2d74-4c50-afa3-1ec803f12efe' = 'ControlPlane' + 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9' = 'ControlPlane' + 'b5a8dcf3-09d5-43a9-a639-8e29ef291470' = 'ControlPlane' + 'b6a27b2b-f905-4b2e-81b5-0d90e0ef1fdb' = 'ControlPlane' + 'b8e31d83-1534-480f-9b10-0338ded51b7e' = 'ControlPlane' + 'baf37b3a-610e-45da-9e62-d9d1e5e8914b' = 'ManagementPlane' + 'be2f45a1-457d-42af-a067-6ec1fa63bc45' = 'ControlPlane' + 'c34f683f-4d5a-4403-affd-6615e00e3a7f' = 'Unclassified' + 'c430b396-e693-46cc-96f3-db01bf8bb62a' = 'ManagementPlane' + 'c4e39bd9-1100-46d3-8c65-fb160da0071f' = 'ControlPlane' + 'cf1c38e5-3621-4004-a7cb-879624dced7c' = 'ManagementPlane' + 'd2562ede-74db-457e-a7b6-544e236ebb61' = 'ControlPlane' + 'd29b2b05-8046-44ba-8758-1e26182fcf32' = 'ControlPlane' + 'd35481f7-cda1-4fa2-8344-5a21f7f3724d' = 'ControlPlane' + 'd37c8bed-0711-4417-ba38-b4abe66ce4c2' = 'ManagementPlane' + 'd405c6df-0af8-4e3b-95e4-4d06e542189e' = 'Unclassified' + 'db506228-d27e-4b7d-95e5-295956d6615f' = 'ControlPlane' + 'dd13091a-6207-4fc0-82ba-3641e056ab95' = 'ManagementPlane' + 'e00e864a-17c5-4a4b-9c06-f5b95a8d5bd8' = 'ControlPlane' + 'e07494ad-1654-4dd2-922e-6f81a71bf00f' = 'ManagementPlane' + 'e0a4caa6-fe82-443f-b92f-d87341d17b2e' = 'ManagementPlane' + 'e300d9e7-4a2b-4295-9eff-f1c78b36cc98' = 'ManagementPlane' + 'e3973bdf-4987-49ae-837a-ba8e231c7286' = 'ManagementPlane' + 'e48398e2-f4bb-4074-8f31-4586725e205b' = 'ManagementPlane' + 'e6d1a23a-da11-4be4-9570-befc86d067a7' = 'ControlPlane' + 'e8611ab8-c189-46e8-94e1-60213ab1f814' = 'ControlPlane' + 'e8cef6f1-e4bd-4ea8-bc07-4b8d950f4477' = 'ManagementPlane' + 'e93e3737-fa85-474a-aee4-7d3fb86510f3' = 'ManagementPlane' + 'eb1f4a8d-243a-41f0-9fbd-c7cdf6c5ef7c' = 'ManagementPlane' + 'ecb2c6bf-0ab6-418e-bd87-7986f8d63bbe' = 'ControlPlane' + 'ee67aa9c-e510-4759-b906-227085a7fd4d' = 'ManagementPlane' + 'f023fd81-a637-4b56-95fd-791ac0226033' = 'ManagementPlane' + 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' = 'ManagementPlane' + 'f2ef992c-3afb-46b9-b7cf-a126ee74c451' = 'ControlPlane' + 'f42252d9-5400-4d7b-b9ef-cc582dbb8577' = 'ControlPlane' + 'f70938a0-fc10-4177-9e90-2178f8765737' = 'ManagementPlane' + 'fc8ad4e2-40e4-4724-8317-bcda7503ecbf' = 'ControlPlane' + 'fcf91098-03e3-41a9-b5ba-6f0ec8188a12' = 'ManagementPlane' + 'fdd7a751-b60b-444a-984c-02652fe8fa1c' = 'ControlPlane' + 'fe930be7-5e62-47db-91af-98c3a49a38b1' = 'ControlPlane' + 'ffd52fa5-98dc-465c-991d-fc073eb59f8f' = 'ControlPlane' + # END AUTO-GENERATED EAM CLASSIFICATION + } +} + +function Get-MtEamClassification { + <# + .SYNOPSIS + Returns the checked-in EntraOps Enterprise Access Model classification table. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param() + + Initialize-MtEamClassification + return $script:MtEamClassification +} diff --git a/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 b/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 index 343a44a56..dc5ac5da3 100644 --- a/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 +++ b/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 @@ -29,10 +29,29 @@ begin { $mgContext = Get-MgContext $tenantId = $mgContext.TenantId + + $EamClassificationError = $null + $FilteredClassification = $null + if ($null -ne $FilteredAccessLevel) { + try { + $EamClassification = Get-MtEamClassification + $FilteredClassification = @( + $EamClassification.GetEnumerator() | + Where-Object { $_.Value -in $FilteredAccessLevel } | + ForEach-Object Key + ) + } catch { + $EamClassificationError = $_ + } + } } process { try { + if ($null -ne $EamClassificationError) { + throw $EamClassificationError + } + $DirectAssignments = Invoke-MtGraphRequest -RelativeUri 'roleManagement/directory/roleAssignments?$expand=principal' -ApiVersion beta $RoleDefinitions = Invoke-MtGraphRequest -RelativeUri 'roleManagement/directory/roleDefinitions' -ApiVersion beta @@ -41,8 +60,6 @@ } if ($null -ne $FilteredAccessLevel) { - $EamClassification = Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/Cloud-Architekt/AzurePrivilegedIAM/main/Classification/Classification_EntraIdDirectoryRoles.json' | ConvertFrom-Json -Depth 10 - $FilteredClassification = ($EamClassification | Where-Object { $_.Classification.EAMTierLevelName -eq $FilteredAccessLevel }).RoleId $DirectAssignments = $DirectAssignments | Where-Object { $_.roleDefinitionId -in $FilteredClassification } } diff --git a/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 b/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 new file mode 100644 index 000000000..bf8285c60 --- /dev/null +++ b/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 @@ -0,0 +1,66 @@ +Describe 'Test-MtPrivPermanentDirectoryRole' { + BeforeEach { + Mock -ModuleName Maester Get-MgContext { + return [pscustomobject]@{ TenantId = 'tenant-id' } + } + Mock -ModuleName Maester Add-MtTestResultDetail + Mock -ModuleName Maester Get-MtEamClassification { + return @{ + 'control-plane-role' = 'ControlPlane' + 'management-plane-role' = 'ManagementPlane' + } + } + Mock -ModuleName Maester Invoke-WebRequest + Mock -ModuleName Maester Invoke-MtGraphRequest { + if ($RelativeUri -like 'roleManagement/directory/roleAssignments*') { + return @( + [pscustomobject]@{ + roleDefinitionId = 'control-plane-role' + principalId = 'user-1' + directoryScopeId = '/' + principal = [pscustomobject]@{ + userType = 'Guest' + displayName = 'Guest User' + id = 'user-1' + '@odata.type' = '#microsoft.graph.user' + } + } + [pscustomobject]@{ + roleDefinitionId = 'management-plane-role' + principalId = 'user-2' + directoryScopeId = '/' + principal = [pscustomobject]@{ + userType = 'Guest' + displayName = 'Management User' + id = 'user-2' + '@odata.type' = '#microsoft.graph.user' + } + } + ) + } + + return @( + [pscustomobject]@{ templateId = 'control-plane-role'; displayName = 'Control Plane Role' } + [pscustomobject]@{ templateId = 'management-plane-role'; displayName = 'Management Plane Role' } + ) + } + } + + It 'uses the checked-in classification and does not call GitHub at test time' { + $result = Test-MtPrivPermanentDirectoryRole -FilteredAccessLevel ControlPlane -FilterPrincipal ExternalUser + + $result | Should -BeTrue + Should -Invoke Get-MtEamClassification -ModuleName Maester -Exactly 1 + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 + Should -Invoke Invoke-MtGraphRequest -ModuleName Maester -Exactly 2 + } + + It 'skips when the checked-in classification cannot be initialized' { + Mock -ModuleName Maester Get-MtEamClassification { throw 'classification unavailable' } + + $result = Test-MtPrivPermanentDirectoryRole -FilteredAccessLevel ControlPlane -FilterPrincipal ExternalUser + + $result | Should -BeNullOrEmpty + Should -Invoke Add-MtTestResultDetail -ModuleName Maester -ParameterFilter { $SkippedBecause -eq 'Error' } + } +} diff --git a/powershell/tests/functions/Update-MtEamClassification.Tests.ps1 b/powershell/tests/functions/Update-MtEamClassification.Tests.ps1 new file mode 100644 index 000000000..a180d3744 --- /dev/null +++ b/powershell/tests/functions/Update-MtEamClassification.Tests.ps1 @@ -0,0 +1,64 @@ +BeforeAll { + . "$PSScriptRoot/../../internal/Get-MtEamClassification.ps1" + + $buildScriptPath = Resolve-Path "$PSScriptRoot/../../../build/Update-MtEamClassification.ps1" + $buildContent = Get-Content $buildScriptPath -Raw + $helperSection = ($buildContent -split [regex]::Escape("Write-Host 'Fetching EAM role classifications from GitHub..."))[0] + . ([scriptblock]::Create($helperSection)) +} + +Describe 'Get-EamClassificationData' { + It 'projects role IDs and EAM tiers from valid source data' { + $json = @( + [pscustomobject]@{ + RoleId = '62E90394-69F5-4237-9190-012177145E10' + Classification = [pscustomobject]@{ EAMTierLevelName = 'ControlPlane' } + } + [pscustomobject]@{ + RoleId = '4a5d8f65-41da-4de4-8968-e035b65339cf' + Classification = [pscustomobject]@{ EAMTierLevelName = 'ManagementPlane' } + } + ) | ConvertTo-Json -Depth 5 + + $result = @(Get-EamClassificationData -Json $json -MinimumRoleCount 2) + + $result.Count | Should -Be 2 + $result[0].RoleId | Should -Be '62e90394-69f5-4237-9190-012177145e10' + $result[0].Tier | Should -Be 'ControlPlane' + } + + It 'rejects duplicate role IDs' { + $json = @( + [pscustomobject]@{ + RoleId = '62e90394-69f5-4237-9190-012177145e10' + Classification = [pscustomobject]@{ EAMTierLevelName = 'ControlPlane' } + } + [pscustomobject]@{ + RoleId = '62e90394-69f5-4237-9190-012177145e10' + Classification = [pscustomobject]@{ EAMTierLevelName = 'ManagementPlane' } + } + ) | ConvertTo-Json -Depth 5 + + { Get-EamClassificationData -Json $json -MinimumRoleCount 1 } | Should -Throw '*duplicate RoleId*' + } + + It 'rejects unknown EAM tiers' { + $json = @{ + RoleId = '62e90394-69f5-4237-9190-012177145e10' + Classification = @{ EAMTierLevelName = 'UnknownPlane' } + } | ConvertTo-Json -Depth 5 + + { Get-EamClassificationData -Json $json -MinimumRoleCount 1 } | Should -Throw '*unknown or empty EAM tier*' + } +} + +Describe 'Get-MtEamClassification' { + It 'contains the generated classification for the current source snapshot' { + $classification = Get-MtEamClassification + + $classification.Count | Should -Be 145 + $classification['62e90394-69f5-4237-9190-012177145e10'] | Should -Be 'ControlPlane' + $classification['a0b1b346-4d3e-4e8b-98f8-753987be4970'] | Should -Be 'UserAccess' + $classification.Values | Should -Contain 'Unclassified' + } +} From 2743b55288d763ba598cdda4b13514e5ac8688fe Mon Sep 17 00:00:00 2001 From: Nathan McNulty Date: Sat, 19 Sep 2026 19:42:10 -0700 Subject: [PATCH 2/7] fix: reuse pinned classification for PIM alerts --- .../maester/entra/Test-MtPimAlertsExists.ps1 | 33 +++++++++++++++++-- .../Test-MtPimAlertsExists.Tests.ps1 | 29 ++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 b/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 index 4866e310b..e025a1b86 100644 --- a/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 +++ b/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 @@ -35,6 +35,27 @@ begin { $mgContext = Get-MgContext $tenantId = $mgContext.TenantId + + $EamClassification = $null + $FilteredClassification = $null + $ClassificationWarning = $null + if ($null -ne $FilteredAccessLevel) { + try { + $EamClassification = Get-MtEamClassification + if ($null -eq $EamClassification -or $EamClassification.Count -eq 0) { + throw 'The EAM classification table is empty.' + } + + $FilteredClassification = @( + $EamClassification.GetEnumerator() | + Where-Object { $_.Value -in $FilteredAccessLevel } | + ForEach-Object Key + ) + } catch { + $ClassificationWarning = 'Enterprise Access Model filtering was unavailable; this result includes all PIM alert assignments.' + Write-Warning "$ClassificationWarning $($_.Exception.Message)" + } + } } process { @@ -59,10 +80,8 @@ } # Filtering based on (EntraOps) Enterprise Access Model Tiering - if ($null -ne $FilteredAccessLevel) { + if ($null -ne $FilteredAccessLevel -and $null -ne $EamClassification) { Write-Verbose 'Filtering based on Enterprise Access Model Tiering' - $EamClassification = Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/Cloud-Architekt/AzurePrivilegedIAM/main/Classification/Classification_EntraIdDirectoryRoles.json' | ConvertFrom-Json - $FilteredClassification = ($EamClassification | Where-Object { $_.Classification.EAMTierLevelName -eq $FilteredAccessLevel }).RoleId $AffectedRoleAssignments = $AffectedRoleAssignments | Where-Object { $_.RoleTemplateId -in $FilteredClassification } } @@ -91,6 +110,10 @@ $($Alert.mitigationSteps -replace $convertHtmlLinkToMD, '[$2]($1)') $($Alert.howToPrevent -replace $convertHtmlLinkToMD, '[$2]($1)') " + if ($null -ne $ClassificationWarning) { + $testDescription += "`n`n**Warning**`n`n$ClassificationWarning" + } + $AffectedRoleAssignmentSummary = @() $AffectedRoleAssignmentSummary += foreach ($AffectedRoleAssignment in $AffectedRoleAssignments) { if ($null -ne $AffectedRoleAssignment.AssigneeDisplayName -or $null -ne $AffectedRoleAssignment.RoleDisplayName) { @@ -109,6 +132,10 @@ Get more details from the PIM alert [$($Alert.alertName)](https://portal.azure.c $testResult = 'All privileged role assignments are managed by PIM. Well done!' } + if ($null -ne $ClassificationWarning) { + $testResult = "$ClassificationWarning`n`n$testResult" + } + Add-MtTestResultDetail -Description $testDescription -Result $testResult return $Alert } catch { diff --git a/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 b/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 index d1bcdd1e3..cbbcaa690 100644 --- a/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 +++ b/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 @@ -1,4 +1,4 @@ -Describe 'Test-MtPimAlertsExists' { +Describe 'Test-MtPimAlertsExists' { BeforeAll { function New-PimAlert { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper creates an in-memory fixture and has no external side effects.')] @@ -63,6 +63,7 @@ Describe 'Test-MtPimAlertsExists' { $script:testResult = $Result $script:skippedBecause = $SkippedBecause } + Mock -ModuleName Maester Invoke-WebRequest } It 'queries the PIM v3 alert endpoint and preserves the existing result contract' { @@ -106,8 +107,11 @@ Describe 'Test-MtPimAlertsExists' { Mock -ModuleName Maester Invoke-MtGraphRequest { return New-PimAlert -AlertIncidents @($controlPlaneIncident, $managementPlaneIncident) } - Mock -ModuleName Maester Invoke-WebRequest { - return '[{"RoleId":"control-plane-role","Classification":{"EAMTierLevelName":"ControlPlane"}},{"RoleId":"management-plane-role","Classification":{"EAMTierLevelName":"ManagementPlane"}}]' + Mock -ModuleName Maester Get-MtEamClassification { + return @{ + 'control-plane-role' = 'ControlPlane' + 'management-plane-role' = 'ManagementPlane' + } } $result = Test-MtPimAlertsExists -AlertId RedundantAssignmentAlert -FilteredAccessLevel ControlPlane -FilteredBreakGlass @() @@ -115,6 +119,25 @@ Describe 'Test-MtPimAlertsExists' { $result.numberOfAffectedItems | Should -Be 1 $script:testResult | Should -Match 'Control User' $script:testResult | Should -Not -Match 'Management User' + Should -Invoke Get-MtEamClassification -ModuleName Maester -Exactly 1 + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 + } + + It 'returns unfiltered incidents with a warning when classification is unavailable' { + $controlPlaneIncident = New-PimAlertIncident -AssigneeId 'user-1' -AssigneeDisplayName 'Control User' -AssigneeUserPrincipalName 'control@contoso.com' -RoleTemplateId 'control-plane-role' + $managementPlaneIncident = New-PimAlertIncident -AssigneeId 'user-2' -AssigneeDisplayName 'Management User' -AssigneeUserPrincipalName 'management@contoso.com' -RoleTemplateId 'management-plane-role' + Mock -ModuleName Maester Invoke-MtGraphRequest { + return New-PimAlert -AlertIncidents @($controlPlaneIncident, $managementPlaneIncident) + } + Mock -ModuleName Maester Get-MtEamClassification { throw 'classification unavailable' } + + $result = Test-MtPimAlertsExists -AlertId RedundantAssignmentAlert -FilteredAccessLevel ControlPlane -FilteredBreakGlass @() + + $result.numberOfAffectedItems | Should -Be 2 + $script:testDescription | Should -Match 'filtering was unavailable' + $script:testResult | Should -Match 'Management User' + $script:skippedBecause | Should -BeNullOrEmpty + Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } It 'excludes break-glass accounts and updates the affected item count' { From 780601927adb07d6c92bfd0fb6191735477dcb87 Mon Sep 17 00:00:00 2001 From: Nathan McNulty Date: Sat, 19 Sep 2026 20:08:27 -0700 Subject: [PATCH 3/7] fix: preserve pipeline role filtering and snapshot provenance --- build/Update-MtEamClassification.ps1 | 43 +++++++++++++++---- .../internal/Get-MtEamClassification.ps1 | 24 ++++++++++- .../Test-MtPrivPermanentDirectoryRole.ps1 | 30 ++++++------- ...est-MtPrivPermanentDirectoryRole.Tests.ps1 | 22 ++++++++++ .../Update-MtEamClassification.Tests.ps1 | 11 ++--- 5 files changed, 99 insertions(+), 31 deletions(-) diff --git a/build/Update-MtEamClassification.ps1 b/build/Update-MtEamClassification.ps1 index c1dc99bf3..d327b413a 100644 --- a/build/Update-MtEamClassification.ps1 +++ b/build/Update-MtEamClassification.ps1 @@ -3,10 +3,13 @@ Updates the generated Enterprise Access Model role classification table. .DESCRIPTION - Downloads the EntraOps directory-role classification at build time, projects + Downloads the EntraOps directory-role classification during maintenance, projects it to the role ID and EAM tier used by Maester, validates the result, and - writes the generated table into the module's internal source. The module does - not fetch this third-party data during a test run. + writes the generated table into the module's internal source. Permanent-role + checks use that table without downloading classification data at runtime. + Run manually when updating the snapshot, review the generated diff, and commit + it with the module. Ordinary module builds do not download or refresh the data. + For reproducibility, pass a commit-pinned raw URL with -SourceUrl. .EXAMPLE ./build/Update-MtEamClassification.ps1 @@ -28,6 +31,7 @@ $ErrorActionPreference = 'Stop' function Get-EamClassificationData { [CmdletBinding()] + [OutputType([System.Collections.Generic.List[hashtable]])] param( [Parameter(Mandatory)] [string] $Json, @@ -36,7 +40,7 @@ function Get-EamClassificationData { [int] $MinimumRoleCount ) - $rows = @($Json | ConvertFrom-Json -Depth 10) + $rows = @($Json | ConvertFrom-Json) if ($rows.Count -lt $MinimumRoleCount) { throw "Only $($rows.Count) EAM role classifications found; expected at least $MinimumRoleCount. Possible parsing issue." } @@ -53,6 +57,7 @@ function Get-EamClassificationData { if (-not [guid]::TryParse($roleId, [ref]$roleGuid)) { throw "Role classification contains an invalid RoleId '$roleId'." } + $roleId = $roleGuid.ToString('D') if ([string]::IsNullOrWhiteSpace($tier) -or $tier -notin $validTiers) { throw "Role '$roleId' contains an unknown or empty EAM tier '$tier'." } @@ -72,6 +77,7 @@ function Get-EamClassificationData { function Get-ClassificationFileContent { [CmdletBinding()] + [OutputType([string])] param( [Parameter(Mandatory)] [System.Collections.Generic.List[hashtable]] $Classification, @@ -93,6 +99,28 @@ function Get-ClassificationFileContent { # Source: $SourceUrl # Source SHA-256: $SourceSha256 # Source rows: $($Classification.Count) +<# +Classification derived from Cloud-Architekt/AzurePrivilegedIAM (MIT License). +Copyright (c) 2024 Thomas Naunheim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +#> function Initialize-MtEamClassification { [CmdletBinding()] @@ -124,7 +152,7 @@ function Get-MtEamClassification { "@ } -Write-Host 'Fetching EAM role classifications from GitHub...' -ForegroundColor Cyan +Write-Verbose 'Fetching EAM role classifications from GitHub...' try { $response = Invoke-WebRequest -Uri $SourceUrl -UseBasicParsing -ErrorAction Stop } catch { @@ -162,7 +190,4 @@ if (Test-Path -LiteralPath $ClassificationPath) { $utf8Bom = [System.Text.UTF8Encoding]::new($true) [System.IO.File]::WriteAllText($resolvedPath, $generatedContent.TrimEnd() + "`n", $utf8Bom) -Write-Host 'Update complete!' -ForegroundColor Green -Write-Host " Roles: $($classification.Count)" -Write-Host " Source hash: $sourceHash" -Write-Host " Updated: $resolvedPath" +Write-Verbose "Updated $resolvedPath with $($classification.Count) roles; source SHA-256: $sourceHash" diff --git a/powershell/internal/Get-MtEamClassification.ps1 b/powershell/internal/Get-MtEamClassification.ps1 index b30349883..422f16b04 100644 --- a/powershell/internal/Get-MtEamClassification.ps1 +++ b/powershell/internal/Get-MtEamClassification.ps1 @@ -1,7 +1,29 @@ # Auto-generated by build/Update-MtEamClassification.ps1. Do not edit manually. -# Source: https://raw.githubusercontent.com/Cloud-Architekt/AzurePrivilegedIAM/main/Classification/Classification_EntraIdDirectoryRoles.json +# Source: https://raw.githubusercontent.com/Cloud-Architekt/AzurePrivilegedIAM/00dce78c522935da86faba6c0b3a73fec2dd0c7a/Classification/Classification_EntraIdDirectoryRoles.json # Source SHA-256: 36de9d95f6ed1949684667de4a05b3e3859d2ed9af41c77dc0fda0fffc08b672 # Source rows: 145 +<# +Classification derived from Cloud-Architekt/AzurePrivilegedIAM (MIT License). +Copyright (c) 2024 Thomas Naunheim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +#> function Initialize-MtEamClassification { [CmdletBinding()] diff --git a/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 b/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 index dc5ac5da3..d6b11f06e 100644 --- a/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 +++ b/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 @@ -30,26 +30,24 @@ $mgContext = Get-MgContext $tenantId = $mgContext.TenantId - $EamClassificationError = $null - $FilteredClassification = $null - if ($null -ne $FilteredAccessLevel) { - try { - $EamClassification = Get-MtEamClassification + $EamClassification = $null + } + + process { + try { + if ($null -ne $FilteredAccessLevel) { + if ($null -eq $EamClassification) { + $EamClassification = Get-MtEamClassification + } + if ($null -eq $EamClassification -or $EamClassification.Count -eq 0) { + throw 'The EAM classification table is empty.' + } + # Pipeline properties are bound for each process invocation, after begin. $FilteredClassification = @( $EamClassification.GetEnumerator() | Where-Object { $_.Value -in $FilteredAccessLevel } | ForEach-Object Key ) - } catch { - $EamClassificationError = $_ - } - } - } - - process { - try { - if ($null -ne $EamClassificationError) { - throw $EamClassificationError } $DirectAssignments = Invoke-MtGraphRequest -RelativeUri 'roleManagement/directory/roleAssignments?$expand=principal' -ApiVersion beta @@ -155,7 +153,7 @@ Add-MtTestResultDetail -Description $testDescription -Result $testResult return $result } catch { - Write-Error "An error occurred while testing Permanent Directory Role Assignments: $_" + Write-Error "An error occurred while testing Permanent Directory Role Assignments: $_" -ErrorAction Continue Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ return $null } diff --git a/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 b/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 index bf8285c60..0a84302ac 100644 --- a/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 +++ b/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 @@ -63,4 +63,26 @@ $result | Should -BeNullOrEmpty Should -Invoke Add-MtTestResultDetail -ModuleName Maester -ParameterFilter { $SkippedBecause -eq 'Error' } } + + It 'honors changing pipeline tiers and loads classification once' { + $results = @( + [pscustomobject]@{ FilterPrincipal = 'ExternalUser'; FilteredAccessLevel = 'ControlPlane' } + [pscustomobject]@{ FilterPrincipal = 'ExternalUser'; FilteredAccessLevel = 'ManagementPlane' } + ) | Test-MtPrivPermanentDirectoryRole + $results.Count | Should -Be 2 + $results | Should -Not -Contain $false + Should -Invoke Get-MtEamClassification -ModuleName Maester -Exactly 1 + Should -Invoke Add-MtTestResultDetail -ModuleName Maester -Exactly 1 -ParameterFilter { + $Result -match 'Guest User' -and $Result -notmatch 'Management User' + } + Should -Invoke Add-MtTestResultDetail -ModuleName Maester -Exactly 1 -ParameterFilter { + $Result -match 'Management User' -and $Result -notmatch 'Guest User' + } + } + + It 'skips an empty classification instead of reporting no assignments' { + Mock -ModuleName Maester Get-MtEamClassification { @{} } + Test-MtPrivPermanentDirectoryRole -FilteredAccessLevel ControlPlane -FilterPrincipal ExternalUser -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + Should -Invoke Add-MtTestResultDetail -ModuleName Maester -Exactly 1 -ParameterFilter { $SkippedBecause -eq 'Error' } + } } diff --git a/powershell/tests/functions/Update-MtEamClassification.Tests.ps1 b/powershell/tests/functions/Update-MtEamClassification.Tests.ps1 index a180d3744..71a502a4e 100644 --- a/powershell/tests/functions/Update-MtEamClassification.Tests.ps1 +++ b/powershell/tests/functions/Update-MtEamClassification.Tests.ps1 @@ -2,16 +2,17 @@ . "$PSScriptRoot/../../internal/Get-MtEamClassification.ps1" $buildScriptPath = Resolve-Path "$PSScriptRoot/../../../build/Update-MtEamClassification.ps1" - $buildContent = Get-Content $buildScriptPath -Raw - $helperSection = ($buildContent -split [regex]::Escape("Write-Host 'Fetching EAM role classifications from GitHub..."))[0] - . ([scriptblock]::Create($helperSection)) + $ast = [System.Management.Automation.Language.Parser]::ParseFile($buildScriptPath, [ref]$null, [ref]$null) + foreach ($function in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)) { + . ([scriptblock]::Create($function.Extent.Text)) + } } Describe 'Get-EamClassificationData' { It 'projects role IDs and EAM tiers from valid source data' { $json = @( [pscustomobject]@{ - RoleId = '62E90394-69F5-4237-9190-012177145E10' + RoleId = '{62E90394-69F5-4237-9190-012177145E10}' Classification = [pscustomobject]@{ EAMTierLevelName = 'ControlPlane' } } [pscustomobject]@{ @@ -30,7 +31,7 @@ Describe 'Get-EamClassificationData' { It 'rejects duplicate role IDs' { $json = @( [pscustomobject]@{ - RoleId = '62e90394-69f5-4237-9190-012177145e10' + RoleId = '62e9039469f542379190012177145e10' Classification = [pscustomobject]@{ EAMTierLevelName = 'ControlPlane' } } [pscustomobject]@{ From bd7babd98e47ce52571021216804d295e3141890 Mon Sep 17 00:00:00 2001 From: Nathan McNulty Date: Sat, 19 Sep 2026 20:13:00 -0700 Subject: [PATCH 4/7] fix: preserve PIM filter scope across pipeline items --- .../maester/entra/Test-MtPimAlertsExists.ps1 | 41 ++++++------------- .../Test-MtPimAlertsExists.Tests.ps1 | 30 +++++++++++--- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 b/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 index e025a1b86..69fad92d2 100644 --- a/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 +++ b/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 @@ -37,25 +37,6 @@ $tenantId = $mgContext.TenantId $EamClassification = $null - $FilteredClassification = $null - $ClassificationWarning = $null - if ($null -ne $FilteredAccessLevel) { - try { - $EamClassification = Get-MtEamClassification - if ($null -eq $EamClassification -or $EamClassification.Count -eq 0) { - throw 'The EAM classification table is empty.' - } - - $FilteredClassification = @( - $EamClassification.GetEnumerator() | - Where-Object { $_.Value -in $FilteredAccessLevel } | - ForEach-Object Key - ) - } catch { - $ClassificationWarning = 'Enterprise Access Model filtering was unavailable; this result includes all PIM alert assignments.' - Write-Warning "$ClassificationWarning $($_.Exception.Message)" - } - } } process { @@ -80,7 +61,19 @@ } # Filtering based on (EntraOps) Enterprise Access Model Tiering - if ($null -ne $FilteredAccessLevel -and $null -ne $EamClassification) { + if ($null -ne $FilteredAccessLevel) { + if ($null -eq $EamClassification) { + $EamClassification = Get-MtEamClassification + } + if ($null -eq $EamClassification -or $EamClassification.Count -eq 0) { + throw 'The EAM classification table is empty.' + } + # Derive each item's tier filter after pipeline property binding. + $FilteredClassification = @( + $EamClassification.GetEnumerator() | + Where-Object { $_.Value -in $FilteredAccessLevel } | + ForEach-Object Key + ) Write-Verbose 'Filtering based on Enterprise Access Model Tiering' $AffectedRoleAssignments = $AffectedRoleAssignments | Where-Object { $_.RoleTemplateId -in $FilteredClassification } } @@ -110,10 +103,6 @@ $($Alert.mitigationSteps -replace $convertHtmlLinkToMD, '[$2]($1)') $($Alert.howToPrevent -replace $convertHtmlLinkToMD, '[$2]($1)') " - if ($null -ne $ClassificationWarning) { - $testDescription += "`n`n**Warning**`n`n$ClassificationWarning" - } - $AffectedRoleAssignmentSummary = @() $AffectedRoleAssignmentSummary += foreach ($AffectedRoleAssignment in $AffectedRoleAssignments) { if ($null -ne $AffectedRoleAssignment.AssigneeDisplayName -or $null -ne $AffectedRoleAssignment.RoleDisplayName) { @@ -132,10 +121,6 @@ Get more details from the PIM alert [$($Alert.alertName)](https://portal.azure.c $testResult = 'All privileged role assignments are managed by PIM. Well done!' } - if ($null -ne $ClassificationWarning) { - $testResult = "$ClassificationWarning`n`n$testResult" - } - Add-MtTestResultDetail -Description $testDescription -Result $testResult return $Alert } catch { diff --git a/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 b/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 index cbbcaa690..075ae3c43 100644 --- a/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 +++ b/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 @@ -123,7 +123,7 @@ Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } - It 'returns unfiltered incidents with a warning when classification is unavailable' { + It 'skips when a requested classification is unavailable' { $controlPlaneIncident = New-PimAlertIncident -AssigneeId 'user-1' -AssigneeDisplayName 'Control User' -AssigneeUserPrincipalName 'control@contoso.com' -RoleTemplateId 'control-plane-role' $managementPlaneIncident = New-PimAlertIncident -AssigneeId 'user-2' -AssigneeDisplayName 'Management User' -AssigneeUserPrincipalName 'management@contoso.com' -RoleTemplateId 'management-plane-role' Mock -ModuleName Maester Invoke-MtGraphRequest { @@ -133,13 +133,33 @@ $result = Test-MtPimAlertsExists -AlertId RedundantAssignmentAlert -FilteredAccessLevel ControlPlane -FilteredBreakGlass @() - $result.numberOfAffectedItems | Should -Be 2 - $script:testDescription | Should -Match 'filtering was unavailable' - $script:testResult | Should -Match 'Management User' - $script:skippedBecause | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty + $script:skippedBecause | Should -Be 'Error' Should -Invoke Invoke-WebRequest -ModuleName Maester -Times 0 } + It 'skips when a requested classification is empty' { + Mock -ModuleName Maester Invoke-MtGraphRequest { New-PimAlert } + Mock -ModuleName Maester Get-MtEamClassification { @{} } + Test-MtPimAlertsExists -AlertId RedundantAssignmentAlert -FilteredAccessLevel ControlPlane -FilteredBreakGlass @() | Should -BeNullOrEmpty + $script:skippedBecause | Should -Be 'Error' + } + + It 'applies changing pipeline tiers while loading classification once' { + $incident = New-PimAlertIncident -AssigneeId 'user-1' -AssigneeDisplayName 'Control User' -RoleTemplateId 'control-plane-role' + Mock -ModuleName Maester Invoke-MtGraphRequest { New-PimAlert -AlertIncidents @($incident) } + Mock -ModuleName Maester Get-MtEamClassification { @{ 'control-plane-role' = 'ControlPlane'; 'management-plane-role' = 'ManagementPlane' } } + # AlertId binds by value; tier binds by property on each string item. + $control = 'RedundantAssignmentAlert' | Add-Member -NotePropertyName FilteredAccessLevel -NotePropertyValue 'ControlPlane' -PassThru + $management = 'StaleSignInAlert' | Add-Member -NotePropertyName FilteredAccessLevel -NotePropertyValue 'ManagementPlane' -PassThru + $results = @($control, $management) | Test-MtPimAlertsExists -FilteredBreakGlass @() + $results.Count | Should -Be 2 + $results[0].numberOfAffectedItems | Should -Be 1 + $results[1].numberOfAffectedItems | Should -Be 0 + Should -Invoke Get-MtEamClassification -ModuleName Maester -Exactly 1 + Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly 0 + } + It 'excludes break-glass accounts and updates the affected item count' { $breakGlassIncident = New-PimAlertIncident -AssigneeId 'break-glass-user' -AssigneeDisplayName 'Emergency Admin' -AssigneeUserPrincipalName 'emergency@contoso.com' $regularIncident = New-PimAlertIncident -AssigneeId 'regular-user' -AssigneeDisplayName 'Regular Admin' -AssigneeUserPrincipalName 'regular@contoso.com' From ef171a26c88c8c2cdccb6db7dfaf9eecfbf37090 Mon Sep 17 00:00:00 2001 From: Nathan McNulty Date: Sat, 19 Sep 2026 20:39:17 -0700 Subject: [PATCH 5/7] Fix EAM source array parsing in Windows PowerShell --- build/Update-MtEamClassification.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build/Update-MtEamClassification.ps1 b/build/Update-MtEamClassification.ps1 index d327b413a..df9497995 100644 --- a/build/Update-MtEamClassification.ps1 +++ b/build/Update-MtEamClassification.ps1 @@ -40,7 +40,8 @@ function Get-EamClassificationData { [int] $MinimumRoleCount ) - $rows = @($Json | ConvertFrom-Json) + $rows = ConvertFrom-Json -InputObject $Json + $rows = @($rows) if ($rows.Count -lt $MinimumRoleCount) { throw "Only $($rows.Count) EAM role classifications found; expected at least $MinimumRoleCount. Possible parsing issue." } From ac06f855d0cc17b0788cdd04e5a1a701bd853dfd Mon Sep 17 00:00:00 2001 From: Nathan McNulty Date: Sat, 19 Sep 2026 20:41:41 -0700 Subject: [PATCH 6/7] Keep filtered PIM results independent of cached alerts --- powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 | 2 ++ .../tests/functions/Test-MtPimAlertsExists.Tests.ps1 | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 b/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 index 69fad92d2..2ecf16332 100644 --- a/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 +++ b/powershell/public/maester/entra/Test-MtPimAlertsExists.ps1 @@ -46,6 +46,8 @@ Write-Verbose 'Getting PIM Alerts' $AlertResourceId = "DirectoryRole_$($tenantId)_$AlertId" $Alert = Invoke-MtGraphRequest -ApiVersion 'beta' -RelativeUri "identityGovernance/roleManagementAlerts/alerts/$($AlertResourceId)?`$expand=alertDefinition,alertConfiguration,alertIncidents" + # Keep per-call compatibility properties and filtered counts out of the Graph cache. + $Alert = $Alert.PSObject.Copy() $AlertDefinition = $Alert.alertDefinition $AffectedRoleAssignments = if ($Alert.isActive) { @($Alert.alertIncidents) } else { @() } diff --git a/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 b/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 index 075ae3c43..8bcc4fe9c 100644 --- a/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 +++ b/powershell/tests/functions/Test-MtPimAlertsExists.Tests.ps1 @@ -147,15 +147,18 @@ It 'applies changing pipeline tiers while loading classification once' { $incident = New-PimAlertIncident -AssigneeId 'user-1' -AssigneeDisplayName 'Control User' -RoleTemplateId 'control-plane-role' - Mock -ModuleName Maester Invoke-MtGraphRequest { New-PimAlert -AlertIncidents @($incident) } + $cachedAlert = New-PimAlert -AlertIncidents @($incident) + Mock -ModuleName Maester Invoke-MtGraphRequest { $cachedAlert } Mock -ModuleName Maester Get-MtEamClassification { @{ 'control-plane-role' = 'ControlPlane'; 'management-plane-role' = 'ManagementPlane' } } # AlertId binds by value; tier binds by property on each string item. $control = 'RedundantAssignmentAlert' | Add-Member -NotePropertyName FilteredAccessLevel -NotePropertyValue 'ControlPlane' -PassThru - $management = 'StaleSignInAlert' | Add-Member -NotePropertyName FilteredAccessLevel -NotePropertyValue 'ManagementPlane' -PassThru + $management = 'RedundantAssignmentAlert' | Add-Member -NotePropertyName FilteredAccessLevel -NotePropertyValue 'ManagementPlane' -PassThru $results = @($control, $management) | Test-MtPimAlertsExists -FilteredBreakGlass @() $results.Count | Should -Be 2 $results[0].numberOfAffectedItems | Should -Be 1 $results[1].numberOfAffectedItems | Should -Be 0 + [object]::ReferenceEquals($results[0], $results[1]) | Should -BeFalse + $cachedAlert.PSObject.Properties.Name | Should -Not -Contain 'numberOfAffectedItems' Should -Invoke Get-MtEamClassification -ModuleName Maester -Exactly 1 Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly 0 } From bfec709fa36ef5cb9fcc7d2f06e8cd01280c33a6 Mon Sep 17 00:00:00 2001 From: Nathan McNulty Date: Sat, 19 Sep 2026 21:17:08 -0700 Subject: [PATCH 7/7] Honor caller error actions in permanent-role checks --- .../Test-MtPrivPermanentDirectoryRole.ps1 | 2 +- ...Test-MtPrivPermanentDirectoryRole.Tests.ps1 | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 b/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 index d6b11f06e..a224d7f0b 100644 --- a/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 +++ b/powershell/public/maester/entra/Test-MtPrivPermanentDirectoryRole.ps1 @@ -153,7 +153,7 @@ Add-MtTestResultDetail -Description $testDescription -Result $testResult return $result } catch { - Write-Error "An error occurred while testing Permanent Directory Role Assignments: $_" -ErrorAction Continue + Write-Error "An error occurred while testing Permanent Directory Role Assignments: $_" Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ return $null } diff --git a/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 b/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 index 0a84302ac..be5947d04 100644 --- a/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 +++ b/powershell/tests/functions/Test-MtPrivPermanentDirectoryRole.Tests.ps1 @@ -58,7 +58,7 @@ It 'skips when the checked-in classification cannot be initialized' { Mock -ModuleName Maester Get-MtEamClassification { throw 'classification unavailable' } - $result = Test-MtPrivPermanentDirectoryRole -FilteredAccessLevel ControlPlane -FilterPrincipal ExternalUser + $result = Test-MtPrivPermanentDirectoryRole -FilteredAccessLevel ControlPlane -FilterPrincipal ExternalUser -ErrorAction SilentlyContinue $result | Should -BeNullOrEmpty Should -Invoke Add-MtTestResultDetail -ModuleName Maester -ParameterFilter { $SkippedBecause -eq 'Error' } @@ -85,4 +85,20 @@ Test-MtPrivPermanentDirectoryRole -FilteredAccessLevel ControlPlane -FilterPrincipal ExternalUser -ErrorAction SilentlyContinue | Should -BeNullOrEmpty Should -Invoke Add-MtTestResultDetail -ModuleName Maester -Exactly 1 -ParameterFilter { $SkippedBecause -eq 'Error' } } + + It 'honors ErrorAction when classification fails' -ForEach @( + @{ Action = 'SilentlyContinue'; ExpectedErrors = 0 } + @{ Action = 'Continue'; ExpectedErrors = 1 } + ) { + Mock -ModuleName Maester Get-MtEamClassification { throw 'classification unavailable' } + $output = @(Test-MtPrivPermanentDirectoryRole -FilteredAccessLevel ControlPlane -FilterPrincipal ExternalUser -ErrorAction $Action 2>&1) + @($output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }).Count | Should -Be $ExpectedErrors + Should -Invoke Add-MtTestResultDetail -ModuleName Maester -Exactly 1 -ParameterFilter { $SkippedBecause -eq 'Error' } + } + + It 'honors ErrorAction Stop when classification fails' { + Mock -ModuleName Maester Get-MtEamClassification { throw 'classification unavailable' } + { Test-MtPrivPermanentDirectoryRole -FilteredAccessLevel ControlPlane -FilterPrincipal ExternalUser -ErrorAction Stop } | + Should -Throw '*classification unavailable*' + } }