From 45f0775b8a71509e89a9f0f3d264ff4b950c73f4 Mon Sep 17 00:00:00 2001 From: Ugur Koc Date: Thu, 23 Jul 2026 19:20:21 +0200 Subject: [PATCH] Release v4.3.1 with least-privilege group access --- .../IntuneAssignmentChecker.psd1 | 12 +++- .../IntuneAssignmentChecker.psm1 | 2 +- .../Connect-IntuneAssignmentChecker.ps1 | 2 +- README.md | 13 ++++- Register-IntuneAssignmentCheckerApp.ps1 | 3 +- Tests/README.md | 4 ++ Tests/Unit/GraphMembership.Tests.ps1 | 55 +++++++++++++++++++ Tests/Unit/PermissionConfiguration.Tests.ps1 | 32 +++++++++++ 8 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 Tests/Unit/GraphMembership.Tests.ps1 create mode 100644 Tests/Unit/PermissionConfiguration.Tests.ps1 diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 index 3d6b4a5..defa2d3 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IntuneAssignmentChecker.psm1' - ModuleVersion = '4.3.0' + ModuleVersion = '4.3.1' GUID = 'c6e25ec6-5787-45ef-95af-8abeb8a17daf' Author = 'Ugur Koc' CompanyName = 'Community' @@ -43,6 +43,16 @@ ProjectUri = 'https://github.com/ugurkocde/IntuneAssignmentChecker' IconUri = '' ReleaseNotes = @' +Version 4.3.1: +Security: +- Replace the broad Group.Read.All permission with GroupMember.Read.All for group lookup, membership, and transitive membership operations. This preserves IntuneAssignmentChecker behavior without granting access to Microsoft 365 group content. + +Upgrade notes: +- Interactive connections automatically request GroupMember.Read.All on the next sign-in. Administrator consent is still required. +- Existing app-only registrations that use a certificate, client secret, managed identity, or pre-fetched token must add GroupMember.Read.All and grant administrator consent before Group.Read.All is removed and revoked. +- Updating requiredResourceAccess alone might not remove an existing service principal consent grant. Confirm that Group.Read.All is revoked after the updated module works. +- Member.Read.Hidden remains optional and is needed only when hidden-membership groups must be resolved. + Version 4.3.0: - Show applications where the checked group is excluded in Get-IntuneGroupAssignment; Compare-IntuneGroupAssignment now marks excluded apps with [EXCLUDED] (issue #126). - Rebuild the ten category-walk cmdlets on a shared scan engine: entity sets are fetched once per run (dozens fewer Graph calls), transient per-category failures no longer abort a run, and errors are raised on the error stream for automation (issue #123). diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psm1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psm1 index b020e2b..22526b3 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psm1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psm1 @@ -21,7 +21,7 @@ $script:IntentTemplateSubtypeToFamily = @{ # Required Microsoft Graph permissions (shared by Connect-IntuneAssignmentChecker and Switch-Tenant) $script:RequiredPermissions = @( @{ Permission = "User.Read.All"; Reason = "Required to read user profile information and check group memberships" } - @{ Permission = "Group.Read.All"; Reason = "Needed to read group information and memberships" } + @{ Permission = "GroupMember.Read.All"; Reason = "Required to read group memberships and basic group properties" } @{ Permission = "DeviceManagementConfiguration.Read.All"; Reason = "Allows reading Intune device configuration policies and their assignments" } @{ Permission = "DeviceManagementApps.Read.All"; Reason = "Necessary to read mobile app management policies and app configurations" } @{ Permission = "DeviceManagementManagedDevices.Read.All"; Reason = "Required to read managed device information and compliance policies" } diff --git a/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 b/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 index 4b75641..f535114 100644 --- a/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 @@ -35,7 +35,7 @@ function Connect-IntuneAssignmentChecker { Write-Host "INTUNE ASSIGNMENT CHECKER" -ForegroundColor Cyan Write-Host "Made by Ugur Koc" -NoNewline Write-Host " | Version" -NoNewline; Write-Host " $localVersion" -ForegroundColor Yellow -NoNewline - Write-Host " | Last updated: " -NoNewline; Write-Host "2026-04-28" -ForegroundColor Magenta + Write-Host " | Last updated: " -NoNewline; Write-Host "2026-07-23" -ForegroundColor Magenta Write-Host "" Write-Host "Feedback & Issues: " -NoNewline -ForegroundColor Cyan Write-Host "https://github.com/ugurkocde/IntuneAssignmentChecker/issues" -ForegroundColor White diff --git a/README.md b/README.md index d61dcb3..369ddd0 100644 --- a/README.md +++ b/README.md @@ -112,10 +112,11 @@ IntuneAssignmentChecker ### Required Permissions Your Entra ID application registration needs these permissions: + | Permission | Type | Description | |------------|------|-------------| | User.Read.All | Application | Read all users' full profiles | -| Group.Read.All | Application | Read all groups | +| GroupMember.Read.All | Application | Read group memberships and basic group properties | | Device.Read.All | Application | Read all devices | | DeviceManagementApps.Read.All | Application | Read Microsoft Intune apps | | DeviceManagementConfiguration.Read.All | Application | Read Microsoft Intune device configuration and policies | @@ -124,6 +125,16 @@ Your Entra ID application registration needs these permissions: | CloudPC.Read.All | Application | Read Windows 365 Cloud PC provisioning policies and settings | | DeviceManagementRBAC.Read.All | Application | Read role scope tags for scope tag display and filtering | +For interactive authentication, IntuneAssignmentChecker automatically requests the delegated versions of these permissions during sign-in. Administrator consent is still required. + +For certificate, client secret, managed identity, or pre-fetched token authentication, configure the listed application permissions on the app registration and grant administrator consent. App-only authentication cannot add or consent permissions automatically. + +`GroupMember.Read.All` provides the group and membership data used by IntuneAssignmentChecker without granting access to Microsoft 365 group content. + +> **Existing app registrations**: Add `GroupMember.Read.All` and grant administrator consent before removing `Group.Read.All`. After confirming the updated module works, remove `Group.Read.All` from the configured API permissions and revoke its application consent. Updating the app registration manifest alone might not remove an existing service principal consent grant. + +> **Hidden memberships**: Reading groups with hidden membership requires the additional `Member.Read.Hidden` application permission. IntuneAssignmentChecker does not request this permission by default. + > **Note**: The automated setup script ([`Register-IntuneAssignmentCheckerApp.ps1`](./Register-IntuneAssignmentCheckerApp.ps1)) additionally grants `DeviceManagementServiceConfig.Read.All`, which covers Intune service configuration such as enrollment settings. It is not validated by `Connect-IntuneAssignmentChecker`, but granting it avoids gaps when reading enrollment-related configurations. ## 🔐 Authentication Options diff --git a/Register-IntuneAssignmentCheckerApp.ps1 b/Register-IntuneAssignmentCheckerApp.ps1 index a68064b..855091b 100644 --- a/Register-IntuneAssignmentCheckerApp.ps1 +++ b/Register-IntuneAssignmentCheckerApp.ps1 @@ -44,7 +44,7 @@ Write-Host "Short Tenant Name: $shortTenantName" -ForegroundColor Green $graphAppId = "00000003-0000-0000-c000-000000000000" $permissions = @( @{ id = "df021288-bdef-4463-88db-98f22de89214"; displayName = "User.Read.All" }, - @{ id = "5b567255-7703-4780-807c-7be8301ae99b"; displayName = "Group.Read.All" }, + @{ id = "98830695-27a2-44f7-8c18-0c3ebc9698f6"; displayName = "GroupMember.Read.All" }, @{ id = "7438b122-aefc-4978-80ed-43db9fcc7715"; displayName = "Device.Read.All" }, @{ id = "7a6ee1e7-141e-4cec-ae74-d9db155731ff"; displayName = "DeviceManagementApps.Read.All" }, @{ id = "dc377aa6-52d8-4e23-b271-2a7ae04cedf3"; displayName = "DeviceManagementConfiguration.Read.All" }, @@ -143,4 +143,3 @@ Write-Host "You can now connect IntuneAssignmentChecker with the following comma Write-Host "Connect-IntuneAssignmentChecker -AppId `"$appId`" -TenantId `"$tenantId`" -CertificateThumbprint `"$certificateThumbprint`"" -ForegroundColor Yellow Write-Host "Afterwards, run 'IntuneAssignmentChecker' to start the interactive menu." -ForegroundColor Cyan Write-Host "----------------------------" - diff --git a/Tests/README.md b/Tests/README.md index 8a3ea6b..54adb49 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -17,6 +17,10 @@ network. Runs in well under a second. filter-suffixed status strings - `Add-ExportData` - regex extraction of FilterName and FilterType from AssignmentReason into dedicated CSV columns +- Permission configuration - verifies the registration script, runtime check, + and README use the least-privilege `GroupMember.Read.All` application role +- Group membership Graph helpers - verifies the transitive group membership + endpoint and pagination behavior **Why these tests matter:** most regressions in this codebase are string-format changes that slip past static analysis. Unit tests at this layer catch them. diff --git a/Tests/Unit/GraphMembership.Tests.ps1 b/Tests/Unit/GraphMembership.Tests.ps1 new file mode 100644 index 0000000..47fa3e0 --- /dev/null +++ b/Tests/Unit/GraphMembership.Tests.ps1 @@ -0,0 +1,55 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $modulePrivate = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker/Private' + + function Invoke-MgGraphRequest { + param([string]$Uri, [string]$Method) + $null = $Uri + $null = $Method + @{ value = @() } + } + + . (Join-Path $modulePrivate 'Get-TransitiveGroupMembership.ps1') + + $script:GraphEndpoint = 'https://graph.test' +} + +Describe 'Get-TransitiveGroupMembership' { + BeforeEach { + $script:requestedUris = [System.Collections.Generic.List[string]]::new() + + Mock Invoke-MgGraphRequest { + $script:requestedUris.Add($Uri) + + if ($Uri -eq 'https://graph.test/v1.0/groups/group-1/transitiveMemberOf/microsoft.graph.group?$select=id,displayName') { + return @{ + value = @( + [PSCustomObject]@{ id = 'parent-1'; displayName = 'Parent One' } + ) + '@odata.nextLink' = 'https://graph.test/v1.0/groups/group-1/transitiveMemberOf/microsoft.graph.group?$skiptoken=next' + } + } + + return @{ + value = @( + [PSCustomObject]@{ id = 'parent-2'; displayName = 'Parent Two' } + ) + } + } + } + + It 'uses the tested transitive group endpoint and follows pagination' { + $result = @(Get-TransitiveGroupMembership -GroupId 'group-1') + + $result.Count | Should -Be 2 + $result[0].id | Should -BeExactly 'parent-1' + $result[1].id | Should -BeExactly 'parent-2' + $script:requestedUris | Should -Be @( + 'https://graph.test/v1.0/groups/group-1/transitiveMemberOf/microsoft.graph.group?$select=id,displayName' + 'https://graph.test/v1.0/groups/group-1/transitiveMemberOf/microsoft.graph.group?$skiptoken=next' + ) + Should -Invoke Invoke-MgGraphRequest -Exactly 2 -ParameterFilter { $Method -eq 'Get' } + } +} diff --git a/Tests/Unit/PermissionConfiguration.Tests.ps1 b/Tests/Unit/PermissionConfiguration.Tests.ps1 new file mode 100644 index 0000000..ea44295 --- /dev/null +++ b/Tests/Unit/PermissionConfiguration.Tests.ps1 @@ -0,0 +1,32 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $repoRoot = Join-Path $PSScriptRoot '../..' + $script:registrationScript = Get-Content -Path (Join-Path $repoRoot 'Register-IntuneAssignmentCheckerApp.ps1') -Raw + $script:moduleScript = Get-Content -Path (Join-Path $repoRoot 'Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psm1') -Raw + $script:readme = Get-Content -Path (Join-Path $repoRoot 'README.md') -Raw +} + +Describe 'Least-privilege group permission configuration' { + It 'configures GroupMember.Read.All as an application role' { + $script:registrationScript | Should -Match '98830695-27a2-44f7-8c18-0c3ebc9698f6' + $script:registrationScript | Should -Match 'displayName\s*=\s*"GroupMember\.Read\.All"' + $script:registrationScript | Should -Match '\$permissions \| ForEach-Object \{ @\{ id = \$_.id; type = "Role" \} \}' + } + + It 'does not configure the broader Group.Read.All application role' { + $script:registrationScript | Should -Not -Match '5b567255-7703-4780-807c-7be8301ae99b' + $script:registrationScript | Should -Not -Match 'displayName\s*=\s*"Group\.Read\.All"' + } + + It 'requires GroupMember.Read.All at runtime' { + $script:moduleScript | Should -Match 'Permission\s*=\s*"GroupMember\.Read\.All"' + $script:moduleScript | Should -Not -Match 'Permission\s*=\s*"Group\.Read\.All"' + } + + It 'documents the least-privilege permission and migration path' { + $script:readme | Should -Match '\|\s*GroupMember\.Read\.All\s*\|\s*Application\s*\|' + $script:readme | Should -Match 'Add `GroupMember\.Read\.All` and grant administrator consent before removing `Group\.Read\.All`' + } +}