From ad50656a6331593a003c4d0454401cc412eeb04c Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:20:39 +0200 Subject: [PATCH 01/13] feat: build PowerShell-native 5.0 governance platform --- .github/workflows/psscriptanalyzer.yml | 3 + .github/workflows/publish-module.yml | 1 + .github/workflows/windows-package.yml | 142 ++++++ .gitignore | 7 + .../Data/GovernanceRules.json | 13 + .../IntuneAssignmentChecker.Format.ps1xml | 96 ++++ .../IntuneAssignmentChecker.psd1 | 26 +- .../IntuneAssignmentChecker.psm1 | 16 +- .../Private/AssignmentHealth.ps1 | 123 ++++++ .../Private/AssignmentSnapshot.ps1 | 53 ++- .../Private/CapabilityProfiles.ps1 | 48 ++ .../Private/Get-IntuneEntities.ps1 | 8 +- .../Private/Governance.ps1 | 79 ++++ .../Private/Invoke-IACGraphRequest.ps1 | 7 + .../Private/Invoke-IntuneCategoryScan.ps1 | 2 +- .../Private/New-IACAssignmentRecord.ps1 | 34 +- .../Private/OperationCatalog.ps1 | 128 ++++++ .../Private/Select-IACAssignmentRecord.ps1 | 1 + .../Private/Show-Menu.ps1 | 55 --- .../Private/StructuredOutput.ps1 | 51 +++ .../Private/Switch-Tenant.ps1 | 70 --- .../Private/TerminalUI.ps1 | 226 ++++++++++ .../Compare-IntuneAssignmentSnapshot.ps1 | 28 +- .../Connect-IntuneAssignmentChecker.ps1 | 48 +- .../ConvertTo-IntuneAssignmentRecord.ps1 | 23 + .../Public/Get-IntuneAssignmentAccess.ps1 | 122 ++++++ .../Public/Get-IntuneAssignmentDrift.ps1 | 194 ++++++++ .../Public/Get-IntuneAssignmentHealth.ps1 | 137 ++++++ .../Public/Get-IntuneAssignmentOperation.ps1 | 32 ++ .../Public/Invoke-IntuneAssignmentChecker.ps1 | 59 +-- .../Invoke-IntuneAssignmentFleetScan.ps1 | 167 +++++++ .../Public/Invoke-IntuneAssignmentScan.ps1 | 217 +++++++++ .../Start-IntuneAssignmentCheckerTui.ps1 | 104 +++++ .../Switch-IntuneAssignmentCheckerTenant.ps1 | 38 ++ .../Public/Test-IntuneAssignmentChange.ps1 | 185 ++++++++ ...est-IntuneAssignmentCheckerEnvironment.ps1 | 108 +++++ .../Public/Test-IntuneAssignmentFilterSet.ps1 | 155 +++++++ .../Test-IntuneAssignmentGovernance.ps1 | 198 +++++++++ .../IntuneAssignmentChecker/Schemas/README.md | 8 + .../Schemas/assignment-record.v2.schema.json | 45 ++ .../assignment-snapshot.v2.schema.json | 37 ++ .../Schemas/drift-event.v1.schema.json | 31 ++ .../Schemas/governance-finding.v1.schema.json | 30 ++ README.md | 224 +++++----- Register-IntuneAssignmentCheckerApp.ps1 | 44 +- Tests/Release/ModulePackage.Tests.ps1 | 7 + Tests/Unit/AssignmentRecord.Tests.ps1 | 10 +- Tests/Unit/AssignmentSnapshot.Tests.ps1 | 36 +- Tests/Unit/GraphTransport.Tests.ps1 | 28 ++ Tests/Unit/V5Platform.Tests.ps1 | 413 ++++++++++++++++++ examples/fleet.config.example.json | 22 + examples/governance-waivers.example.json | 14 + packaging/Build-WindowsInstaller.ps1 | 80 ++++ packaging/IntuneAssignmentChecker.wxs | 31 ++ packaging/New-WinGetManifest.ps1 | 103 +++++ packaging/README.md | 18 + 56 files changed, 3827 insertions(+), 358 deletions(-) create mode 100644 .github/workflows/windows-package.yml create mode 100644 .gitignore create mode 100644 Module/IntuneAssignmentChecker/Data/GovernanceRules.json create mode 100644 Module/IntuneAssignmentChecker/Private/AssignmentHealth.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/CapabilityProfiles.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/Governance.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/OperationCatalog.ps1 delete mode 100644 Module/IntuneAssignmentChecker/Private/Show-Menu.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/StructuredOutput.ps1 delete mode 100644 Module/IntuneAssignmentChecker/Private/Switch-Tenant.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/TerminalUI.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/ConvertTo-IntuneAssignmentRecord.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentAccess.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentDrift.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentHealth.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentOperation.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentFleetScan.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentScan.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Switch-IntuneAssignmentCheckerTenant.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentChange.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentCheckerEnvironment.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentFilterSet.ps1 create mode 100644 Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentGovernance.ps1 create mode 100644 Module/IntuneAssignmentChecker/Schemas/README.md create mode 100644 Module/IntuneAssignmentChecker/Schemas/assignment-record.v2.schema.json create mode 100644 Module/IntuneAssignmentChecker/Schemas/assignment-snapshot.v2.schema.json create mode 100644 Module/IntuneAssignmentChecker/Schemas/drift-event.v1.schema.json create mode 100644 Module/IntuneAssignmentChecker/Schemas/governance-finding.v1.schema.json create mode 100644 Tests/Unit/V5Platform.Tests.ps1 create mode 100644 examples/fleet.config.example.json create mode 100644 examples/governance-waivers.example.json create mode 100644 packaging/Build-WindowsInstaller.ps1 create mode 100644 packaging/IntuneAssignmentChecker.wxs create mode 100644 packaging/New-WinGetManifest.ps1 create mode 100644 packaging/README.md diff --git a/.github/workflows/psscriptanalyzer.yml b/.github/workflows/psscriptanalyzer.yml index 1fd13c9..e29ecfa 100644 --- a/.github/workflows/psscriptanalyzer.yml +++ b/.github/workflows/psscriptanalyzer.yml @@ -7,6 +7,7 @@ on: - 'Module/**' - 'Tests/**' - 'Register-IntuneAssignmentCheckerApp.ps1' + - 'packaging/**' - '.PSScriptAnalyzerSettings.psd1' - '.github/workflows/psscriptanalyzer.yml' pull_request: @@ -15,6 +16,7 @@ on: - 'Module/**' - 'Tests/**' - 'Register-IntuneAssignmentCheckerApp.ps1' + - 'packaging/**' - '.PSScriptAnalyzerSettings.psd1' - '.github/workflows/psscriptanalyzer.yml' workflow_dispatch: @@ -49,6 +51,7 @@ jobs: Get-ChildItem ./Module/IntuneAssignmentChecker -Recurse -File -Include *.ps1, *.psm1 Get-ChildItem ./Tests -Recurse -File -Include *.ps1 Get-Item ./Register-IntuneAssignmentCheckerApp.ps1 + Get-ChildItem ./packaging -File -Filter *.ps1 ) | Sort-Object FullName $results = @( foreach ($file in $files) { diff --git a/.github/workflows/publish-module.yml b/.github/workflows/publish-module.yml index 828314b..cebfd9f 100644 --- a/.github/workflows/publish-module.yml +++ b/.github/workflows/publish-module.yml @@ -52,6 +52,7 @@ jobs: Get-ChildItem ./Module/IntuneAssignmentChecker -Recurse -File -Include *.ps1, *.psm1 Get-ChildItem ./Tests -Recurse -File -Include *.ps1 Get-Item ./Register-IntuneAssignmentCheckerApp.ps1 + Get-ChildItem ./packaging -File -Filter *.ps1 ) | Sort-Object FullName $results = @( foreach ($file in $files) { diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml new file mode 100644 index 0000000..53d4feb --- /dev/null +++ b/.github/workflows/windows-package.yml @@ -0,0 +1,142 @@ +name: Windows MSI and WinGet + +on: + pull_request: + branches: [main] + paths: + - 'Module/**' + - 'packaging/**' + - '.github/workflows/windows-package.yml' + push: + branches: [main] + paths: + - 'Module/**' + - 'packaging/**' + - '.github/workflows/windows-package.yml' + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + id-token: write + attestations: write + +jobs: + build: + name: Build and verify MSI + runs-on: windows-latest + timeout-minutes: 30 + env: + SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE }} + SIGNING_PASSWORD: ${{ secrets.WINDOWS_SIGNING_PASSWORD }} + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install WiX + shell: pwsh + run: | + dotnet tool install --global wix --version 6.0.2 + wix --version + + - name: Build MSI + id: package + shell: pwsh + run: | + $manifest = Import-PowerShellDataFile ./Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 + $version = "$($manifest.ModuleVersion)" + if ('${{ github.event_name }}' -eq 'release') { + $tagVersion = '${{ github.event.release.tag_name }}' -replace '^v', '' + if ($tagVersion -cne $version) { throw "Release tag version '$tagVersion' does not match '$version'." } + } + $package = ./packaging/Build-WindowsInstaller.ps1 -Version $version -OutputDirectory ./artifacts + "version=$version" >> $env:GITHUB_OUTPUT + "installer=$($package.Path)" >> $env:GITHUB_OUTPUT + "product_code=$($package.ProductCode)" >> $env:GITHUB_OUTPUT + $package | Format-List + + - name: Require release signing credentials + if: github.event_name == 'release' + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:SIGNING_CERTIFICATE) -or [string]::IsNullOrWhiteSpace($env:SIGNING_PASSWORD)) { + throw 'WINDOWS_SIGNING_CERTIFICATE and WINDOWS_SIGNING_PASSWORD secrets are required for a release MSI.' + } + + - name: Sign release MSI + if: github.event_name == 'release' + shell: pwsh + run: | + $certificatePath = Join-Path $env:RUNNER_TEMP 'intune-assignment-checker-signing.pfx' + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:SIGNING_CERTIFICATE)) + & signtool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com /f $certificatePath /p $env:SIGNING_PASSWORD '${{ steps.package.outputs.installer }}' + if ($LASTEXITCODE -ne 0) { throw 'Authenticode signing failed.' } + & signtool verify /pa /v '${{ steps.package.outputs.installer }}' + if ($LASTEXITCODE -ne 0) { throw 'Authenticode signature verification failed.' } + Remove-Item -LiteralPath $certificatePath -Force + + - name: Verify installation and removal + shell: pwsh + run: | + $msi = '${{ steps.package.outputs.installer }}' + $install = Start-Process msiexec.exe -ArgumentList @('/i', $msi, '/qn', '/norestart') -Wait -PassThru + if ($install.ExitCode -ne 0) { throw "MSI installation failed with exit code $($install.ExitCode)." } + Import-Module IntuneAssignmentChecker -RequiredVersion '${{ steps.package.outputs.version }}' -Force + $module = Get-Module IntuneAssignmentChecker + if ($module.Version.ToString() -cne '${{ steps.package.outputs.version }}') { throw 'Installed module version mismatch.' } + $catalog = @(Get-IntuneAssignmentOperation) + $expected = @($module.ExportedFunctions.Keys | Where-Object { $_ -notin @('Get-IntuneAssignmentOperation', 'Invoke-IntuneAssignmentChecker', 'Start-IntuneAssignmentCheckerTui') }) + if (@(Compare-Object $expected $catalog.Name).Count -gt 0) { throw 'TUI operation catalog is not in parity with installed exports.' } + Remove-Module IntuneAssignmentChecker + $remove = Start-Process msiexec.exe -ArgumentList @('/x', '${{ steps.package.outputs.product_code }}', '/qn', '/norestart') -Wait -PassThru + if ($remove.ExitCode -ne 0) { throw "MSI removal failed with exit code $($remove.ExitCode)." } + + - name: Generate WinGet manifests + shell: pwsh + run: | + $version = '${{ steps.package.outputs.version }}' + $url = "https://github.com/${{ github.repository }}/releases/download/v$version/IntuneAssignmentChecker-$version-x64.msi" + ./packaging/New-WinGetManifest.ps1 -InstallerPath '${{ steps.package.outputs.installer }}' ` + -InstallerUrl $url -Version $version -ProductCode '${{ steps.package.outputs.product_code }}' ` + -OutputDirectory ./artifacts/winget + + - name: Generate SBOM + uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 + with: + path: ./artifacts/windows-package-staging + format: spdx-json + output-file: ./artifacts/IntuneAssignmentChecker-${{ steps.package.outputs.version }}.spdx.json + upload-artifact: false + + - name: Upload build artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0b # v7.0.1 + with: + name: intune-assignment-checker-windows-${{ steps.package.outputs.version }} + path: | + artifacts/*.msi + artifacts/*.spdx.json + artifacts/winget/*.yaml + if-no-files-found: error + + - name: Attest release installer + if: github.event_name == 'release' + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-path: '${{ steps.package.outputs.installer }}' + + - name: Upload release assets + if: github.event_name == 'release' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $assets = @( + (Get-Item -LiteralPath '${{ steps.package.outputs.installer }}').FullName + (Get-Item -LiteralPath "./artifacts/IntuneAssignmentChecker-${{ steps.package.outputs.version }}.spdx.json").FullName + (Get-ChildItem -LiteralPath ./artifacts/winget -File -Filter *.yaml).FullName + ) + if ($assets.Count -lt 5) { throw 'Expected the MSI, SBOM, and three WinGet manifest files.' } + gh release upload '${{ github.event.release.tag_name }}' @assets --clobber + if ($LASTEXITCODE -ne 0) { throw 'GitHub release asset upload failed.' } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8368641 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +artifacts/ +TestResults/ +*.user +*.suo +.vs/ diff --git a/Module/IntuneAssignmentChecker/Data/GovernanceRules.json b/Module/IntuneAssignmentChecker/Data/GovernanceRules.json new file mode 100644 index 0000000..779f0b6 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Data/GovernanceRules.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "rules": [ + { "id": "IAC001", "enabled": true, "severity": "High", "title": "Broad All Users targeting" }, + { "id": "IAC002", "enabled": true, "severity": "High", "title": "Broad All Devices targeting" }, + { "id": "IAC003", "enabled": true, "severity": "Medium", "title": "Required application has no exclusion" }, + { "id": "IAC004", "enabled": true, "severity": "High", "title": "Empty or unresolved target group" }, + { "id": "IAC005", "enabled": true, "severity": "Critical", "title": "Conflicting inclusion and exclusion" }, + { "id": "IAC006", "enabled": true, "severity": "Medium", "title": "Assignment filter result is unknown" }, + { "id": "IAC007", "enabled": true, "severity": "Critical", "title": "Assignment scan coverage is incomplete" }, + { "id": "IAC008", "enabled": true, "severity": "High", "title": "Critical policy or application is unassigned" } + ] +} diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.Format.ps1xml b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.Format.ps1xml index 1c8a8e1..0373dd7 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.Format.ps1xml +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.Format.ps1xml @@ -27,5 +27,101 @@ + + IntuneAssignmentChecker.GovernanceFinding + IntuneAssignmentChecker.GovernanceFinding + + + 10 + 8 + 30 + 22 + + + + Severity + RuleId + PolicyName + TargetName + Message + + + + + IntuneAssignmentChecker.AssignmentDriftEvent + IntuneAssignmentChecker.AssignmentDriftEvent + + + 10 + 10 + 24 + 34 + + + + ChangeType + Risk + CategoryId + PolicyName + AuditActor + + + + + IntuneAssignmentChecker.AssignmentHealth + IntuneAssignmentChecker.AssignmentHealth + + + 20 + 10 + 30 + 24 + 14 + + + + Workload + RecordType + PolicyName + DeviceName + DeliveryState + ReportingState + + + + + IntuneAssignmentChecker.AssignmentAccess + IntuneAssignmentChecker.AssignmentAccess + + + 16 + 28 + 28 + + + + BoundaryStatus + RoleAssignmentName + RoleDefinitionName + PolicyName + + + + + IntuneAssignmentChecker.OperationDescriptor + IntuneAssignmentChecker.OperationDescriptor + + + 20 + 40 + + + + Category + Name + Synopsis + + + diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 index a0aa329..5fe06bb 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IntuneAssignmentChecker.psm1' - ModuleVersion = '4.4.0' + ModuleVersion = '5.0.0' GUID = 'c6e25ec6-5787-45ef-95af-8abeb8a17daf' Author = 'Ugur Koc' CompanyName = 'Community' @@ -11,6 +11,7 @@ FunctionsToExport = @( 'Invoke-IntuneAssignmentChecker' 'Connect-IntuneAssignmentChecker' + 'Switch-IntuneAssignmentCheckerTenant' 'Get-IntuneUserAssignment' 'Get-IntuneGroupAssignment' 'Get-IntuneDeviceAssignment' @@ -32,6 +33,18 @@ 'Search-IntunePolicy' 'Search-IntuneSetting' 'Update-IntuneSettingDefinition' + 'Get-IntuneAssignmentOperation' + 'Start-IntuneAssignmentCheckerTui' + 'Test-IntuneAssignmentCheckerEnvironment' + 'Test-IntuneAssignmentGovernance' + 'Test-IntuneAssignmentChange' + 'Get-IntuneAssignmentDrift' + 'Invoke-IntuneAssignmentFleetScan' + 'Test-IntuneAssignmentFilterSet' + 'Get-IntuneAssignmentAccess' + 'Get-IntuneAssignmentHealth' + 'ConvertTo-IntuneAssignmentRecord' + 'Invoke-IntuneAssignmentScan' ) CmdletsToExport = @() VariablesToExport = @() @@ -39,6 +52,12 @@ FormatsToProcess = @('IntuneAssignmentChecker.Format.ps1xml') FileList = @( 'Data/SettingDefinitions.json' + 'Data/GovernanceRules.json' + 'Schemas/assignment-record.v2.schema.json' + 'Schemas/assignment-snapshot.v2.schema.json' + 'Schemas/governance-finding.v1.schema.json' + 'Schemas/drift-event.v1.schema.json' + 'Schemas/README.md' 'html-export.ps1' ) PrivateData = @{ @@ -48,6 +67,11 @@ ProjectUri = 'https://github.com/ugurkocde/IntuneAssignmentChecker' IconUri = '' ReleaseNotes = @' +Version 5.0.0: +- Add a PowerShell-native terminal UI whose dynamic operation catalog stays in parity with exported module commands. +- Add assignment governance, change simulation, drift attribution, fleet orchestration, delivery health, RBAC analysis, filter-set governance, capability-based authentication, and environment diagnostics. +- Add schema-governed structured output, MSI packaging, and WinGet release automation without converting the module to an executable. + Version 4.4.0: - Centralize every Microsoft Graph call behind a beta-only transport with automatic paging, bounded retry/backoff, nextLink validation, and structured error metadata (issue #136). - Add schema-versioned IntuneAssignmentChecker.AssignmentRecord objects and non-interactive -PassThru output to the primary assignment and policy-search cmdlets (issue #137). diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psm1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psm1 index 22526b3..ae14132 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psm1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psm1 @@ -9,6 +9,8 @@ $script:CurrentTenantName = $null $script:CurrentUserUPN = $null $script:TemplateIdToFamilyCache = $null $script:ScopeTagLookup = $null +$script:RequestedCapabilities = @('Full') +$script:CapabilityStatus = @() $script:IntentTemplateSubtypeToFamily = @{ 'antivirus' = 'endpointSecurityAntivirus' 'diskEncryption' = 'endpointSecurityDiskEncryption' @@ -18,7 +20,7 @@ $script:IntentTemplateSubtypeToFamily = @{ 'accountProtection' = 'endpointSecurityAccountProtection' } -# Required Microsoft Graph permissions (shared by Connect-IntuneAssignmentChecker and Switch-Tenant) +# Required Microsoft Graph permissions used by connection and tenant-switch commands. $script:RequiredPermissions = @( @{ Permission = "User.Read.All"; Reason = "Required to read user profile information and check group memberships" } @{ Permission = "GroupMember.Read.All"; Reason = "Required to read group memberships and basic group properties" } @@ -29,8 +31,20 @@ $script:RequiredPermissions = @( @{ Permission = "DeviceManagementScripts.Read.All"; Reason = "Needed to read device management and health scripts" } @{ Permission = "CloudPC.Read.All"; Reason = "Required to read Windows 365 Cloud PC provisioning policies and settings (optional if W365 not licensed)" } @{ Permission = "DeviceManagementRBAC.Read.All"; Reason = "Required to read role scope tags for scope tag display and filtering" } + @{ Permission = "DeviceManagementServiceConfig.Read.All"; Reason = "Required to read Autopilot deployment profiles and enrollment status page configurations" } ) +$script:CapabilityProfiles = [ordered]@{ + Core = @('User.Read.All', 'GroupMember.Read.All', 'DeviceManagementConfiguration.Read.All', 'DeviceManagementServiceConfig.Read.All') + Applications = @('DeviceManagementApps.Read.All') + Devices = @('DeviceManagementManagedDevices.Read.All', 'Device.Read.All') + Scripts = @('DeviceManagementScripts.Read.All') + CloudPC = @('CloudPC.Read.All') + ScopeTags = @('DeviceManagementRBAC.Read.All') + Audit = @('DeviceManagementApps.Read.All') + Full = @() +} + # Dot-source all private functions $Private = @(Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -ErrorAction SilentlyContinue) foreach ($file in $Private) { diff --git a/Module/IntuneAssignmentChecker/Private/AssignmentHealth.ps1 b/Module/IntuneAssignmentChecker/Private/AssignmentHealth.ps1 new file mode 100644 index 0000000..20e2666 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/AssignmentHealth.ps1 @@ -0,0 +1,123 @@ +function ConvertFrom-IACReportResponse { + [CmdletBinding()] + param([AllowNull()]$Response) + + if ($Response -is [string]) { + try { $Response = $Response | ConvertFrom-Json -Depth 30 -ErrorAction Stop } + catch { throw "The Intune report endpoint returned invalid JSON: $($_.Exception.Message)" } + } + if ($Response -is [System.Collections.IDictionary] -and $Response.Contains('body') -and $Response.body -is [string]) { + $Response = $Response.body | ConvertFrom-Json -Depth 30 -ErrorAction Stop + } + return $Response +} + +function ConvertFrom-IACReportRows { + [CmdletBinding()] + param([Parameter(Mandatory)]$Report) + + $columns = @($Report.Schema | ForEach-Object Column) + foreach ($values in @($Report.Values)) { + $row = [ordered]@{} + for ($index = 0; $index -lt $columns.Count; $index++) { + $row["$($columns[$index])"] = if ($index -lt @($values).Count) { $values[$index] } else { $null } + } + [PSCustomObject]$row + } +} + +function ConvertTo-IACDeliveryState { + [CmdletBinding()] + param([AllowNull()]$Status) + + if ($Status -is [byte] -or $Status -is [int16] -or $Status -is [int32] -or $Status -is [int64]) { + $mappedState = switch ([int]$Status) { + '1' { 'NotApplicable' } + '2' { 'Succeeded' } + '3' { 'Failed' } + '4' { 'Conflict' } + '5' { 'Pending' } + default { 'Unknown' } + } + return $mappedState + } + switch -Regex ("$Status") { + '^(?i:installed|success|succeeded|compliant|remediated)$' { 'Succeeded' } + '^(?i:failed|error|uninstallFailed|nonCompliant)$' { 'Failed' } + '^(?i:conflict)$' { 'Conflict' } + '^(?i:notApplicable)$' { 'NotApplicable' } + '^(?i:pending|pendingInstall|notInstalled|notEvaluated)$' { 'Pending' } + default { 'Unknown' } + } +} + +function New-IACAssignmentHealthRecord { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Workload, + [Parameter(Mandatory)][string]$PolicyId, + [Parameter(Mandatory)][string]$PolicyName, + [AllowNull()][string]$DeviceId, + [AllowNull()][string]$DeviceName, + [AllowNull()][string]$UserPrincipalName, + [AllowNull()]$RawStatus, + [AllowNull()][string]$Detail, + [AllowNull()]$LastReportedDateTime, + [Parameter(Mandatory)][timespan]$StaleAfter + ) + + $state = ConvertTo-IACDeliveryState -Status $RawStatus + $reported = [datetimeoffset]::MinValue + $styles = [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal + $hasReported = [datetimeoffset]::TryParse("$LastReportedDateTime", [Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$reported) + $record = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentHealth' + SchemaVersion = 1 + RecordType = 'Status' + TenantId = $script:CurrentTenantId + Workload = $Workload + PolicyId = $PolicyId + PolicyName = $PolicyName + DeviceId = $DeviceId + DeviceName = $DeviceName + UserPrincipalName = $UserPrincipalName + TargetingState = 'Targeted' + Applicability = if ($state -eq 'NotApplicable') { 'NotApplicable' } elseif ($state -eq 'Unknown') { 'Unknown' } else { 'Applicable' } + DeliveryState = $state + RawStatus = $RawStatus + Detail = $Detail + LastReportedUtc = if ($hasReported) { $reported.ToUniversalTime().ToString('o') } else { $null } + ReportingState = if (-not $hasReported) { 'NeverReported' } elseif ([datetimeoffset]::UtcNow - $reported -gt $StaleAfter) { 'Stale' } else { 'Current' } + } + $record.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentHealth') + return $record +} + +function New-IACAssignmentHealthCoverage { + [CmdletBinding()] + param([string]$Workload, [string]$Status, [string]$Message) + + $record = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentHealth' + SchemaVersion = 1 + RecordType = 'Coverage' + TenantId = $script:CurrentTenantId + Workload = $Workload + CoverageStatus = $Status + CoverageMessage = $Message + PolicyId = $null + PolicyName = $null + DeviceId = $null + DeviceName = $null + UserPrincipalName = $null + TargetingState = $null + Applicability = $null + DeliveryState = $null + RawStatus = $null + Detail = $null + LastReportedUtc = $null + ReportingState = $null + } + $record.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentHealth') + return $record +} diff --git a/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 b/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 index cbaba5c..67da2b2 100644 --- a/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 +++ b/Module/IntuneAssignmentChecker/Private/AssignmentSnapshot.ps1 @@ -3,7 +3,8 @@ function Get-IACAssignmentRecordPropertyNames { param() @( - 'SchemaVersion', 'TenantId', 'TenantName', 'SubjectType', 'SubjectId', 'SubjectName', + 'SchemaName', 'SchemaVersion', 'RecordId', 'GraphApiVersion', + 'TenantId', 'TenantName', 'SubjectType', 'SubjectId', 'SubjectName', 'CategoryId', 'Category', 'PolicyId', 'PolicyName', 'Platform', 'ScopeTagIds', 'ScopeTags', 'AssignmentId', 'AssignmentMode', 'TargetType', 'TargetId', 'TargetName', 'Intent', 'FilterId', 'FilterName', 'FilterMode', 'FilterRule', 'FilterPlatform', 'EffectiveState', @@ -11,6 +12,24 @@ function Get-IACAssignmentRecordPropertyNames { ) } +function Test-IACCoverageHasBlockingFailure { + [CmdletBinding()] + param([AllowNull()]$Coverage) + + if ($null -eq $Coverage -or [bool]$Coverage.Complete) { return $false } + $skippedCategories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $hasBlockingStatus = $false + foreach ($category in @($Coverage.Categories)) { + if ($category.Status -ceq 'Skipped') { [void]$skippedCategories.Add("$($category.CategoryId)") } + elseif ($category.Status -in @('Failed', 'Unknown')) { $hasBlockingStatus = $true } + } + if ($hasBlockingStatus) { return $true } + foreach ($coverageError in @($Coverage.Errors)) { + if (-not $skippedCategories.Contains("$($coverageError.CategoryId)")) { return $true } + } + return $skippedCategories.Count -eq 0 +} + function Get-IACOrdinalSortedUniqueString { [CmdletBinding()] param( @@ -60,14 +79,20 @@ function ConvertTo-IACSnapshotRecord { ) process { - $requiredProperties = Get-IACAssignmentRecordPropertyNames + $requiredProperties = @( + 'SchemaVersion', 'TenantId', 'TenantName', 'SubjectType', 'SubjectId', 'SubjectName', + 'CategoryId', 'Category', 'PolicyId', 'PolicyName', 'Platform', 'ScopeTagIds', 'ScopeTags', + 'AssignmentId', 'AssignmentMode', 'TargetType', 'TargetId', 'TargetName', 'Intent', + 'FilterId', 'FilterName', 'FilterMode', 'FilterRule', 'FilterPlatform', 'EffectiveState', + 'ReasonChain', 'AssignmentReason', 'Source' + ) $missing = @($requiredProperties | Where-Object { $null -eq $InputObject.PSObject.Properties[$_] }) if ($missing.Count -gt 0) { throw "Snapshot input is not a canonical assignment record; missing properties: $($missing -join ', ')." } $recordSchemaVersion = 0 - if (-not [int]::TryParse("$($InputObject.SchemaVersion)", [ref]$recordSchemaVersion) -or $recordSchemaVersion -ne 1) { - throw "Assignment record schema version '$($InputObject.SchemaVersion)' is not supported; expected version 1." + if (-not [int]::TryParse("$($InputObject.SchemaVersion)", [ref]$recordSchemaVersion) -or $recordSchemaVersion -notin @(1, 2)) { + throw "Assignment record schema version '$($InputObject.SchemaVersion)' is not supported; expected version 1 or 2." } if ([string]::IsNullOrWhiteSpace("$($InputObject.CategoryId)")) { throw 'Snapshot input contains an assignment record without CategoryId.' @@ -130,8 +155,11 @@ function ConvertTo-IACSnapshotRecord { } } - [PSCustomObject][ordered]@{ - SchemaVersion = 1 + $record = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentRecord' + SchemaVersion = 2 + RecordId = $null + GraphApiVersion = 'beta' TenantId = $InputObject.TenantId TenantName = $InputObject.TenantName SubjectType = $InputObject.SubjectType @@ -162,6 +190,9 @@ function ConvertTo-IACSnapshotRecord { AssignmentReason = $InputObject.AssignmentReason Source = $InputObject.Source } + $record.RecordId = Get-IACAssignmentRecordId -Record $record + $record.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentRecord') + $record } } @@ -254,7 +285,7 @@ function New-IACAssignmentSnapshot { [PSCustomObject][ordered]@{ SchemaName = 'IntuneAssignmentChecker.AssignmentSnapshot' - SchemaVersion = 1 + SchemaVersion = 2 CapturedAtUtc = $CapturedAtUtc.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ', [System.Globalization.CultureInfo]::InvariantCulture) ModuleVersion = Get-IACInstalledModuleVersion Tenant = [PSCustomObject][ordered]@{ @@ -308,8 +339,8 @@ function Read-IACAssignmentSnapshot { throw "Assignment snapshot '$Path' has schema '$($snapshot.SchemaName)'; expected 'IntuneAssignmentChecker.AssignmentSnapshot'." } $snapshotSchemaVersion = 0 - if (-not [int]::TryParse("$($snapshot.SchemaVersion)", [ref]$snapshotSchemaVersion) -or $snapshotSchemaVersion -ne 1) { - throw "Assignment snapshot '$Path' uses unsupported schema version '$($snapshot.SchemaVersion)'; expected version 1." + if (-not [int]::TryParse("$($snapshot.SchemaVersion)", [ref]$snapshotSchemaVersion) -or $snapshotSchemaVersion -notin @(1, 2)) { + throw "Assignment snapshot '$Path' uses unsupported schema version '$($snapshot.SchemaVersion)'; expected version 1 or 2." } foreach ($property in @('CapturedAtUtc', 'ModuleVersion', 'Tenant', 'Coverage', 'Records')) { if ($null -eq $snapshot.PSObject.Properties[$property]) { @@ -425,5 +456,9 @@ function Read-IACAssignmentSnapshot { } $snapshot.Records = @($recordsByKey.Values) + if ($snapshotSchemaVersion -eq 1) { + $snapshot | Add-Member -NotePropertyName MigratedFromSchemaVersion -NotePropertyValue 1 -Force + $snapshot.SchemaVersion = 2 + } return $snapshot } diff --git a/Module/IntuneAssignmentChecker/Private/CapabilityProfiles.ps1 b/Module/IntuneAssignmentChecker/Private/CapabilityProfiles.ps1 new file mode 100644 index 0000000..aa573f2 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/CapabilityProfiles.ps1 @@ -0,0 +1,48 @@ +function Resolve-IACCapabilityPermission { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet('Core', 'Applications', 'Devices', 'Scripts', 'CloudPC', 'ScopeTags', 'Audit', 'Full')] + [string[]]$Capability + ) + + $selected = if ($Capability -contains 'Full') { @($script:CapabilityProfiles.Keys | Where-Object { $_ -ne 'Full' }) } + else { @($Capability) } + $permissionNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($capabilityName in $selected) { + foreach ($permission in @($script:CapabilityProfiles[$capabilityName])) { [void]$permissionNames.Add($permission) } + } + @($script:RequiredPermissions | Where-Object { $permissionNames.Contains($_.Permission) }) +} + +function Get-IACCapabilityStatus { + [CmdletBinding()] + param( + [AllowNull()][string[]]$GrantedPermission, + [switch]$AppOnly + ) + + foreach ($capabilityName in @($script:CapabilityProfiles.Keys | Where-Object { $_ -ne 'Full' } | Sort-Object)) { + $required = @($script:CapabilityProfiles[$capabilityName]) + $missing = if ($AppOnly) { @() } + else { + @($required | Where-Object { + $permission = $_ + $GrantedPermission -notcontains $permission -and + $GrantedPermission -notcontains $permission.Replace('.Read', '.ReadWrite') + }) + } + [PSCustomObject][ordered]@{ + Name = $capabilityName + Requested = $script:RequestedCapabilities -contains 'Full' -or $script:RequestedCapabilities -contains $capabilityName + Status = if (-not ($script:RequestedCapabilities -contains 'Full' -or $script:RequestedCapabilities -contains $capabilityName)) { + 'Skipped' + } + elseif ($AppOnly) { 'Unknown' } + elseif ($missing.Count -eq 0) { 'Available' } + else { 'Unavailable' } + RequiredPermissions = $required + MissingPermissions = @($missing) + } + } +} diff --git a/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 b/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 index 0c2939a..fecddb5 100644 --- a/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Get-IntuneEntities.ps1 @@ -16,7 +16,12 @@ function Get-IntuneEntities { # Optional beta workloads are not available in every tenant. Callers can # suppress the expected warning without changing the empty-result contract. [Parameter(Mandatory = $false)] - [switch]$Quiet + [switch]$Quiet, + + # Preserve legacy empty-result behavior by default, while coverage-aware + # callers can require a terminating error for an unavailable workload. + [Parameter(Mandatory = $false)] + [switch]$ThrowOnError ) # Handle special cases for app management and specific deviceManagement endpoints @@ -41,6 +46,7 @@ function Get-IntuneEntities { if ($pagedEntities.Count -gt 0) { $entities.AddRange([object[]]$pagedEntities) } } catch { + if ($ThrowOnError) { throw } $errorMessage = $_.Exception.Message $statusCode = if ($_.Exception.Data.Contains('StatusCode')) { $_.Exception.Data['StatusCode'] } else { $null } if ($statusCode -eq 403 -or $errorMessage -match "403|Forbidden|Authorization_RequestDenied") { diff --git a/Module/IntuneAssignmentChecker/Private/Governance.ps1 b/Module/IntuneAssignmentChecker/Private/Governance.ps1 new file mode 100644 index 0000000..dc82e68 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/Governance.ps1 @@ -0,0 +1,79 @@ +function Get-IACGovernanceRule { + [CmdletBinding()] + param([string]$RulePath) + + $defaultPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Data/GovernanceRules.json' + $path = if ($RulePath) { $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($RulePath) } else { $defaultPath } + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Governance rule file '$path' does not exist." } + $document = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 20 -ErrorAction Stop + if ([int]$document.schemaVersion -ne 1) { throw "Governance rule schema version '$($document.schemaVersion)' is not supported." } + $known = @('IAC001', 'IAC002', 'IAC003', 'IAC004', 'IAC005', 'IAC006', 'IAC007', 'IAC008') + foreach ($rule in @($document.rules)) { + if ($rule.id -notin $known) { throw "Unknown governance rule '$($rule.id)'." } + if ($rule.severity -notin @('Low', 'Medium', 'High', 'Critical')) { throw "Rule '$($rule.id)' has invalid severity '$($rule.severity)'." } + } + return @($document.rules) +} + +function New-IACGovernanceFinding { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Rule, + [Parameter(Mandatory)][string]$Message, + [Parameter(Mandatory)][string]$Remediation, + [AllowNull()]$Record, + [AllowNull()]$Evidence, + [AllowNull()]$Waiver + ) + + $identity = "$($Rule.id)|$($Record.TenantId)|$($Record.PolicyId)|$($Record.AssignmentId)|$($Record.TargetId)|$Message" + $findingId = (Get-IACSha256Hex -InputText $identity).Substring(0, 24) + $finding = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.GovernanceFinding' + SchemaVersion = 1 + FindingId = $findingId + RuleId = "$($Rule.id)" + Severity = "$($Rule.severity)" + Title = "$($Rule.title)" + Message = $Message + TenantId = $Record.TenantId + TenantName = $Record.TenantName + CategoryId = $Record.CategoryId + PolicyId = $Record.PolicyId + PolicyName = $Record.PolicyName + AssignmentId = $Record.AssignmentId + TargetId = $Record.TargetId + TargetName = $Record.TargetName + Evidence = $Evidence + Remediation = $Remediation + Suppressed = $null -ne $Waiver + Waiver = $Waiver + DetectedAtUtc = [datetimeoffset]::UtcNow.ToString('o') + } + $finding.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.GovernanceFinding') + return $finding +} + +function Find-IACGovernanceWaiver { + [CmdletBinding()] + param( + [AllowEmptyCollection()][object[]]$Waiver = @(), + [Parameter(Mandatory)][string]$RuleId, + [AllowNull()][string]$PolicyId, + [AllowNull()][string]$TargetId + ) + + foreach ($entry in @($Waiver)) { + if ($entry.RuleId -ne $RuleId) { continue } + if ($entry.PolicyId -and $entry.PolicyId -ne $PolicyId) { continue } + if ($entry.TargetId -and $entry.TargetId -ne $TargetId) { continue } + $expiration = [datetimeoffset]::MinValue + $styles = [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal + if (-not [datetimeoffset]::TryParse("$($entry.ExpiresAtUtc)", [Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$expiration)) { + throw "Waiver expiration '$($entry.ExpiresAtUtc)' is not a valid UTC date-time." + } + if ($expiration -le [datetimeoffset]::UtcNow) { continue } + return $entry + } + return $null +} diff --git a/Module/IntuneAssignmentChecker/Private/Invoke-IACGraphRequest.ps1 b/Module/IntuneAssignmentChecker/Private/Invoke-IACGraphRequest.ps1 index b856127..1f1a0d6 100644 --- a/Module/IntuneAssignmentChecker/Private/Invoke-IACGraphRequest.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Invoke-IACGraphRequest.ps1 @@ -16,6 +16,9 @@ function Invoke-IACGraphRequest { [Parameter()] [switch]$AllPages, + [Parameter()] + [switch]$FirstPageOnly, + [Parameter()] [ValidateRange(0, 10)] [int]$MaxRetryCount = 3, @@ -28,6 +31,9 @@ function Invoke-IACGraphRequest { if ([string]::IsNullOrWhiteSpace($script:GraphEndpoint)) { throw 'Microsoft Graph is not connected. Run Connect-IntuneAssignmentChecker first.' } + if ($AllPages -and $FirstPageOnly) { + throw '-AllPages and -FirstPageOnly cannot be used together.' + } $graphBase = $script:GraphEndpoint.TrimEnd('/') $requestUri = $Uri.Trim() @@ -198,6 +204,7 @@ function Invoke-IACGraphRequest { if ($null -eq $firstResponse) { $firstResponse = $response } $nextLink = if ($response) { $response.'@odata.nextLink' } else { $null } + if ($FirstPageOnly) { return $response } if (-not $AllPages -and $pageCount -eq 1 -and [string]::IsNullOrWhiteSpace($nextLink)) { return $response } diff --git a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 index 2f7c00d..76b5059 100644 --- a/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Invoke-IntuneCategoryScan.ps1 @@ -55,7 +55,7 @@ function Invoke-IntuneCategoryScan { function Get-CachedEntitySet { param([string]$EntityType, [switch]$Quiet) if (-not $EntityCache.ContainsKey($EntityType)) { - $EntityCache[$EntityType] = @(Get-IntuneEntities -EntityType $EntityType -Quiet:$Quiet) + $EntityCache[$EntityType] = @(Get-IntuneEntities -EntityType $EntityType -Quiet:$Quiet -ThrowOnError) } return , @($EntityCache[$EntityType]) } diff --git a/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 b/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 index 544bb1d..23051b6 100644 --- a/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 +++ b/Module/IntuneAssignmentChecker/Private/New-IACAssignmentRecord.ps1 @@ -1,3 +1,29 @@ +function Get-IACSha256Hex { + [CmdletBinding()] + param([Parameter(Mandatory)][AllowEmptyString()][string]$InputText) + + $algorithm = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($InputText) + $hash = $algorithm.ComputeHash($bytes) + return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant() + } + finally { + $algorithm.Dispose() + } +} + +function Get-IACAssignmentRecordId { + [CmdletBinding()] + param([Parameter(Mandatory)]$Record) + + $identity = @( + $Record.SubjectType, $Record.SubjectId, $Record.CategoryId, $Record.PolicyId, + $(if ($Record.AssignmentId) { "id:$($Record.AssignmentId)" } else { "fallback:$($Record.AssignmentMode)|$($Record.TargetType)|$($Record.TargetId)|$($Record.Intent)" }) + ) -join "`u{001f}" + Get-IACSha256Hex -InputText $identity +} + function New-IACAssignmentRecord { [CmdletBinding()] param( @@ -41,7 +67,10 @@ function New-IACAssignmentRecord { ) $record = [PSCustomObject][ordered]@{ - SchemaVersion = 1 + SchemaName = 'IntuneAssignmentChecker.AssignmentRecord' + SchemaVersion = 2 + RecordId = $null + GraphApiVersion = 'beta' TenantId = $script:CurrentTenantId TenantName = $script:CurrentTenantName SubjectType = $SubjectType @@ -65,11 +94,12 @@ function New-IACAssignmentRecord { FilterMode = $FilterMode FilterRule = $FilterRule FilterPlatform = $FilterPlatform - EffectiveState = $EffectiveState + EffectiveState = if ([string]::IsNullOrWhiteSpace($EffectiveState)) { $null } else { $EffectiveState } ReasonChain = @($ReasonChain) AssignmentReason = $AssignmentReason Source = $Source } + $record.RecordId = Get-IACAssignmentRecordId -Record $record $record.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentRecord') return $record } diff --git a/Module/IntuneAssignmentChecker/Private/OperationCatalog.ps1 b/Module/IntuneAssignmentChecker/Private/OperationCatalog.ps1 new file mode 100644 index 0000000..86c6687 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/OperationCatalog.ps1 @@ -0,0 +1,128 @@ +function Get-IACOperationCategory { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Name) + + switch -Regex ($Name) { + '^(Connect-|Switch-IntuneAssignmentCheckerTenant)' { return 'Connection' } + '(Governance|Change|FilterSet|Access|Fleet)' { return 'Governance' } + '(Snapshot|Drift)' { return 'Drift' } + '(Health|Failed)' { return 'Delivery health' } + '^(Search-|Update-IntuneSettingDefinition)' { return 'Discovery' } + '^(Export-|New-)' { return 'Reporting' } + '^(Test-)' { return 'Simulation' } + default { return 'Assignments' } + } +} + +function Get-IACOperationCapability { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Name) + + switch -Regex ($Name) { + '^(Connect-|Switch-IntuneAssignmentCheckerTenant)' { return @('Core') } + '(Access|ScopeTag)' { return @('ScopeTags') } + '(Drift|Governance)' { return @('Core', 'Audit') } + '(Health|Failed)' { return @('Applications', 'Devices') } + '(Application|App|Protection)' { return @('Applications') } + '(Device|Filter)' { return @('Devices') } + '(Script|Setting)' { return @('Scripts') } + '(CloudPC)' { return @('CloudPC') } + default { return @('Core') } + } +} + +function Get-IACOperationCatalog { + [CmdletBinding()] + param() + + $infrastructureCommands = @( + 'Get-IntuneAssignmentOperation', + 'Invoke-IntuneAssignmentChecker', + 'Start-IntuneAssignmentCheckerTui' + ) + $commonParameters = @([System.Management.Automation.PSCmdlet]::CommonParameters) + + @([System.Management.Automation.PSCmdlet]::OptionalCommonParameters) + $module = Get-Module -Name IntuneAssignmentChecker | Select-Object -First 1 + if (-not $module) { + throw 'The IntuneAssignmentChecker module must be imported before its operation catalog can be created.' + } + $legacySynopsis = @{ + 'Compare-IntuneGroupAssignment' = 'Compares the Intune assignments that target two or more groups.' + 'Connect-IntuneAssignmentChecker' = 'Connects to Microsoft Graph with the selected capability profile.' + 'Get-IntuneAllDevicesAssignment' = 'Lists policies and applications assigned to all devices.' + 'Get-IntuneAllPolicies' = 'Lists every supported Intune policy and its assignments.' + 'Get-IntuneAllUsersAssignment' = 'Lists policies and applications assigned to all users.' + 'Get-IntuneDeviceAssignment' = 'Finds effective Intune assignments for one or more devices.' + 'Get-IntuneEmptyGroup' = 'Finds assigned groups that currently have no members.' + 'Get-IntuneFailedAssignment' = 'Reports failed Intune policy and application deployments.' + 'Get-IntuneGroupAssignment' = 'Finds Intune assignments that include or exclude selected groups.' + 'Get-IntuneUnassignedPolicy' = 'Finds supported Intune policies that have no assignments.' + 'Get-IntuneUserAssignment' = 'Finds effective Intune assignments for one or more users.' + 'New-IntuneHTMLReport' = 'Creates HTML and optional CSV assignment reports.' + 'Search-IntunePolicy' = 'Searches supported Intune policies and their assignments.' + 'Search-IntuneSetting' = 'Searches Intune policy settings by keyword.' + 'Test-IntuneGroupMembership' = 'Simulates the assignment impact of adding a user or device to a group.' + 'Test-IntuneGroupRemoval' = 'Simulates the assignment impact of removing a user or device from a group.' + 'Update-IntuneSettingDefinition' = 'Refreshes the local Intune setting-definition catalog.' + } + + foreach ($command in @($module.ExportedFunctions.Values | Sort-Object Name)) { + if ($command.Name -in $infrastructureCommands) { continue } + + $help = Get-Help -Name $command.Name -ErrorAction SilentlyContinue + $parameterSets = foreach ($parameterSet in @($command.ParameterSets)) { + $parameters = foreach ($parameter in @($parameterSet.Parameters | Where-Object Name -NotIn $commonParameters)) { + $validateSet = @($parameter.Attributes | Where-Object { + $_ -is [System.Management.Automation.ValidateSetAttribute] + } | ForEach-Object ValidValues) + $helpMessage = @($parameter.Attributes | Where-Object { + $_ -is [System.Management.Automation.ParameterAttribute] + } | ForEach-Object HelpMessage | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -First 1) + if ($helpMessage.Count -eq 0 -and $help -and $help.Parameters) { + $parameterHelp = @($help.Parameters.Parameter | Where-Object Name -EQ $parameter.Name | Select-Object -First 1) + $parameterDescription = @($parameterHelp.Description.Text | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ' ' + if (-not [string]::IsNullOrWhiteSpace($parameterDescription)) { $helpMessage = @($parameterDescription.Trim()) } + } + + [PSCustomObject][ordered]@{ + Name = $parameter.Name + Type = $parameter.ParameterType.FullName + TypeName = $parameter.ParameterType.Name + Mandatory = [bool]$parameter.IsMandatory + Position = $parameter.Position + ValueFromPipeline = [bool]$parameter.ValueFromPipeline + IsSwitch = $parameter.ParameterType -eq [System.Management.Automation.SwitchParameter] + IsArray = $parameter.ParameterType.IsArray + ValidateSet = @($validateSet) + HelpMessage = if ($helpMessage.Count -gt 0) { "$($helpMessage[0])" } else { $null } + } + } + + [PSCustomObject][ordered]@{ + Name = $parameterSet.Name + IsDefault = [bool]$parameterSet.IsDefault + Parameters = @($parameters) + } + } + + $synopsis = if ($help -and $help.Synopsis) { "$($help.Synopsis)".Trim() } else { '' } + if ([string]::IsNullOrWhiteSpace($synopsis) -or $synopsis.StartsWith($command.Name, [System.StringComparison]::OrdinalIgnoreCase)) { + $synopsis = if ($legacySynopsis.ContainsKey($command.Name)) { $legacySynopsis[$command.Name] } else { $command.Name } + } + $descriptor = [PSCustomObject][ordered]@{ + SchemaVersion = 1 + Name = $command.Name + Category = Get-IACOperationCategory -Name $command.Name + Capabilities = @(Get-IACOperationCapability -Name $command.Name) + Synopsis = $synopsis + Description = if ($help -and $help.Description) { + (@($help.Description.Text) -join [Environment]::NewLine).Trim() + } + else { $null } + ParameterSets = @($parameterSets) + } + $descriptor.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.OperationDescriptor') + $descriptor + } +} diff --git a/Module/IntuneAssignmentChecker/Private/Select-IACAssignmentRecord.ps1 b/Module/IntuneAssignmentChecker/Private/Select-IACAssignmentRecord.ps1 index 999a588..0f4a480 100644 --- a/Module/IntuneAssignmentChecker/Private/Select-IACAssignmentRecord.ps1 +++ b/Module/IntuneAssignmentChecker/Private/Select-IACAssignmentRecord.ps1 @@ -42,6 +42,7 @@ function Select-IACAssignmentRecord { if ($copy.TargetType -eq 'Group' -and $copy.TargetId -and -not $copy.TargetName) { $copy.TargetName = (Get-GroupInfo -GroupId $copy.TargetId).DisplayName } + if ($copy.PSObject.Properties['RecordId']) { $copy.RecordId = Get-IACAssignmentRecordId -Record $copy } $copy.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentRecord') $copy } diff --git a/Module/IntuneAssignmentChecker/Private/Show-Menu.ps1 b/Module/IntuneAssignmentChecker/Private/Show-Menu.ps1 deleted file mode 100644 index c4f33c4..0000000 --- a/Module/IntuneAssignmentChecker/Private/Show-Menu.ps1 +++ /dev/null @@ -1,55 +0,0 @@ -function Show-Menu { - [CmdletBinding()] - param() - # Display current connection status - if ($script:CurrentTenantName -and $script:CurrentUserUPN) { - Write-Host "Connected to: " -ForegroundColor Green -NoNewline - Write-Host "$script:CurrentTenantName" -ForegroundColor White - Write-Host "Logged in as: " -ForegroundColor Green -NoNewline - Write-Host "$script:CurrentUserUPN" -ForegroundColor White - Write-Host "" - } - elseif ($script:CurrentUserUPN) { - Write-Host "Logged in as: " -ForegroundColor Green -NoNewline - Write-Host "$script:CurrentUserUPN" -ForegroundColor White - Write-Host "" - } - else { - Write-Host "Status: " -ForegroundColor Yellow -NoNewline - Write-Host "Not Connected" -ForegroundColor Red - Write-Host "" - } - - Write-Host "Assignment Checks:" -ForegroundColor Cyan - Write-Host " [1] Check User(s) Assignments" -ForegroundColor White - Write-Host " [2] Check Group(s) Assignments" -ForegroundColor White - Write-Host " [3] Check Device(s) Assignments" -ForegroundColor White - Write-Host "" - - Write-Host "Policy Overview:" -ForegroundColor Cyan - Write-Host " [4] Show All Policies and Their Assignments" -ForegroundColor White - Write-Host " [5] Show All 'All Users' Assignments" -ForegroundColor White - Write-Host " [6] Show All 'All Devices' Assignments" -ForegroundColor White - Write-Host "" - - Write-Host "Advanced Options:" -ForegroundColor Cyan - Write-Host " [7] Generate HTML Report" -ForegroundColor White - Write-Host " [8] Show Policies and Apps Without Assignments" -ForegroundColor White - Write-Host " [9] Check for Empty Groups in Assignments" -ForegroundColor White - Write-Host " [10] Compare Assignments Between Groups" -ForegroundColor White - Write-Host " [11] Show All Failed Assignments" -ForegroundColor White - Write-Host " [12] Simulate Group Membership Impact (User and/or Device)" -ForegroundColor White - Write-Host " [13] Simulate Removing from Group (User and/or Device)" -ForegroundColor White - Write-Host " [14] Search Policy Assignments" -ForegroundColor White - Write-Host " [15] Search for Specific Settings" -ForegroundColor White - Write-Host " [16] What-If: All Policies for a User on a Specific Device" -ForegroundColor White - Write-Host "" - - Write-Host "System:" -ForegroundColor Cyan - Write-Host " [T] Switch Tenant" -ForegroundColor White - Write-Host " [0] Exit" -ForegroundColor White - Write-Host " [98] Support the Project [99] Report a Bug or Request a Feature" -ForegroundColor DarkGray - Write-Host "" - - Write-Host "Select an option: " -ForegroundColor Yellow -NoNewline -} diff --git a/Module/IntuneAssignmentChecker/Private/StructuredOutput.ps1 b/Module/IntuneAssignmentChecker/Private/StructuredOutput.ps1 new file mode 100644 index 0000000..ef4c1db --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/StructuredOutput.ps1 @@ -0,0 +1,51 @@ +function Export-IACStructuredOutput { + [CmdletBinding()] + param( + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$InputObject, + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][ValidateSet('Json', 'JsonLines', 'Csv')][string]$Format + ) + + $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + $parent = Split-Path -Parent $resolvedPath + if ($parent -and -not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + switch ($Format) { + 'Json' { + $content = ConvertTo-Json -InputObject @($InputObject) -Depth 30 + [System.IO.File]::WriteAllText($resolvedPath, "$($content.TrimEnd())`n", [System.Text.UTF8Encoding]::new($false)) + } + 'JsonLines' { + $lines = foreach ($item in @($InputObject)) { ConvertTo-Json -InputObject $item -Depth 30 -Compress } + $content = if (@($lines).Count -gt 0) { (@($lines) -join "`n") + "`n" } else { '' } + [System.IO.File]::WriteAllText($resolvedPath, $content, [System.Text.UTF8Encoding]::new($false)) + } + 'Csv' { + @($InputObject) | ForEach-Object { + $row = [ordered]@{} + foreach ($property in $_.PSObject.Properties) { + $csvValue = if ($null -eq $property.Value -or $property.Value -is [string] -or + $property.Value -is [ValueType]) { $property.Value } + else { ConvertTo-Json -InputObject $property.Value -Depth 20 -Compress } + $row[$property.Name] = ConvertTo-IACCsvSafeValue -Value $csvValue + } + [PSCustomObject]$row + } | Export-Csv -LiteralPath $resolvedPath -NoTypeInformation -Encoding utf8 + } + } + return $resolvedPath +} + +function Get-IACSeverityRank { + [CmdletBinding()] + param([AllowNull()][string]$Severity) + + switch ($Severity) { + 'Critical' { 4 } + 'High' { 3 } + 'Medium' { 2 } + 'Low' { 1 } + default { 0 } + } +} diff --git a/Module/IntuneAssignmentChecker/Private/Switch-Tenant.ps1 b/Module/IntuneAssignmentChecker/Private/Switch-Tenant.ps1 deleted file mode 100644 index cef0895..0000000 --- a/Module/IntuneAssignmentChecker/Private/Switch-Tenant.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -function Switch-Tenant { - [CmdletBinding()] - param() - Write-Host "`nDisconnecting from current tenant..." -ForegroundColor Yellow - - try { - # Disconnect from current Graph session - Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null - - # Clear tenant variables - $script:CurrentTenantId = $null - $script:CurrentTenantName = $null - $script:CurrentUserUPN = $null - - Write-Host "Disconnected successfully." -ForegroundColor Green - Write-Host "" - - # Prompt for new connection - Write-Host "Please log in to connect to a different tenant..." -ForegroundColor Cyan - - # Get required permissions - $permissionsList = ($script:RequiredPermissions | ForEach-Object { $_.Permission }) -join ', ' - - # Prompt for environment selection - $environment = Set-Environment - if ($null -eq $environment) { - Write-Host "Tenant switch cancelled." -ForegroundColor Yellow - return - } - - # Attempt new connection - $null = Connect-MgGraph -Scopes $permissionsList -Environment $script:GraphEnvironment -NoWelcome -ErrorAction Stop - - # Get and store new tenant context - $context = Get-MgContext - if ($context) { - $script:CurrentTenantId = $context.TenantId - $script:CurrentUserUPN = $context.Account - - # Try to get tenant display name - try { - $org = Invoke-IACGraphRequest -Method GET -Uri "$script:GraphEndpoint/beta/organization" -ErrorAction SilentlyContinue - if ($org.value -and $org.value.Count -gt 0) { - $script:CurrentTenantName = $org.value[0].displayName - } - } - catch { - # If we can't get the display name, use tenant ID - $script:CurrentTenantName = $context.TenantId - } - - Write-Host "`nSuccessfully connected to new tenant!" -ForegroundColor Green - Write-Host "Tenant: $script:CurrentTenantName" -ForegroundColor White - Write-Host "User: $script:CurrentUserUPN" -ForegroundColor White - - # Refresh scope tag lookup for the new tenant - $script:ScopeTagLookup = Get-ScopeTagLookup - - # Refresh assignment filter lookup for the new tenant - $script:AssignmentFilterLookup = Get-AssignmentFilterLookup - - # Clear cached group info from the previous tenant - $script:GroupInfoCache = $null - } - } - catch { - Write-Host "Failed to connect to new tenant: $_" -ForegroundColor Red - Write-Host "You may need to reconnect manually." -ForegroundColor Yellow - } -} diff --git a/Module/IntuneAssignmentChecker/Private/TerminalUI.ps1 b/Module/IntuneAssignmentChecker/Private/TerminalUI.ps1 new file mode 100644 index 0000000..4601bfa --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/TerminalUI.ps1 @@ -0,0 +1,226 @@ +function Test-IACVirtualTerminal { + [CmdletBinding()] + param() + + if ($env:NO_COLOR) { return $false } + try { return [bool]$Host.UI.SupportsVirtualTerminal } + catch { return $false } +} + +function Write-IACTuiText { + [CmdletBinding()] + param( + [Parameter(Mandatory)][AllowEmptyString()][string]$Text, + [ValidateSet('Default', 'Accent', 'Selected', 'Muted', 'Success', 'Warning', 'Error')] + [string]$Style = 'Default', + [switch]$NoNewline + ) + + $colors = @{ + Default = "`e[0m" + Accent = "`e[1;36m" + Selected = "`e[30;46m" + Muted = "`e[90m" + Success = "`e[32m" + Warning = "`e[33m" + Error = "`e[31m" + } + if (Test-IACVirtualTerminal) { + Write-Host "$($colors[$Style])$Text`e[0m" -NoNewline:$NoNewline + } + else { + $consoleColor = switch ($Style) { + 'Accent' { 'Cyan' } + 'Selected' { 'Black' } + 'Muted' { 'DarkGray' } + 'Success' { 'Green' } + 'Warning' { 'Yellow' } + 'Error' { 'Red' } + default { 'Gray' } + } + if ($Style -eq 'Selected') { + Write-Host $Text -ForegroundColor Black -BackgroundColor Cyan -NoNewline:$NoNewline + } + else { + Write-Host $Text -ForegroundColor $consoleColor -NoNewline:$NoNewline + } + } +} + +function Read-IACTuiParameterValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Parameter, + [Parameter(Mandatory)][string]$CommandName + ) + + $required = if ($Parameter.Mandatory) { 'required' } else { 'optional; Enter skips' } + $choices = if (@($Parameter.ValidateSet).Count -gt 0) { + " Choices: $(@($Parameter.ValidateSet) -join ', ')." + } + else { '' } + Write-IACTuiText -Text "`n-$($Parameter.Name)" -Style Accent + Write-IACTuiText -Text " $($Parameter.TypeName); $required.$choices" -Style Muted + if ($Parameter.HelpMessage) { Write-IACTuiText -Text " $($Parameter.HelpMessage)" -Style Muted } + + if ($Parameter.IsSwitch) { + $answer = Read-Host ' Enable? (y/N)' + return [PSCustomObject]@{ Supplied = $answer -match '^(?i:y|yes)$'; Value = $true } + } + if ($Parameter.TypeName -eq 'SecureString') { + $answer = Read-Host ' Supply this secret? (y/N)' + if ($answer -notmatch '^(?i:y|yes)$') { + if ($Parameter.Mandatory) { throw "-$($Parameter.Name) is required for $CommandName." } + return [PSCustomObject]@{ Supplied = $false; Value = $null } + } + return [PSCustomObject]@{ Supplied = $true; Value = (Read-Host ' Secret' -AsSecureString) } + } + if ($Parameter.TypeName -eq 'PSCredential') { + $answer = Read-Host ' Supply a credential? (y/N)' + if ($answer -notmatch '^(?i:y|yes)$') { + if ($Parameter.Mandatory) { throw "-$($Parameter.Name) is required for $CommandName." } + return [PSCustomObject]@{ Supplied = $false; Value = $null } + } + return [PSCustomObject]@{ Supplied = $true; Value = (Get-Credential -Message "$CommandName -$($Parameter.Name)") } + } + + while ($true) { + $value = Read-Host ' Value' + if ([string]::IsNullOrWhiteSpace($value)) { + if ($Parameter.Mandatory) { + Write-IACTuiText -Text " A value is required." -Style Warning + continue + } + return [PSCustomObject]@{ Supplied = $false; Value = $null } + } + if ($Parameter.IsArray) { + if ($value.StartsWith('@') -and (Test-Path -LiteralPath $value.Substring(1) -PathType Leaf)) { + try { + $items = @(Get-Content -LiteralPath $value.Substring(1) -Raw -ErrorAction Stop | + ConvertFrom-Json -Depth 30 -ErrorAction Stop) + } + catch { + Write-IACTuiText -Text " Could not read JSON input: $($_.Exception.Message)" -Style Warning + continue + } + } + else { $items = @($value -split ',' | ForEach-Object Trim | Where-Object { $_ }) } + $invalidItems = if (@($Parameter.ValidateSet).Count -gt 0) { + @($items | Where-Object { $_ -notin $Parameter.ValidateSet }) + } + else { @() } + if ($invalidItems.Count -gt 0) { + Write-IACTuiText -Text " Invalid value(s): $($invalidItems -join ', '). Choose from: $(@($Parameter.ValidateSet) -join ', ')." -Style Warning + continue + } + try { + $targetType = [System.Management.Automation.PSTypeName]::new("$($Parameter.Type)").Type + $convertedItems = if ($targetType) { + [System.Management.Automation.LanguagePrimitives]::ConvertTo($items, $targetType) + } + else { $items } + return [PSCustomObject]@{ Supplied = $true; Value = $convertedItems } + } + catch { + Write-IACTuiText -Text " Could not convert the value: $($_.Exception.Message)" -Style Warning + continue + } + } + if (@($Parameter.ValidateSet).Count -gt 0 -and $value -notin $Parameter.ValidateSet) { + Write-IACTuiText -Text " Choose one of: $(@($Parameter.ValidateSet) -join ', ')." -Style Warning + continue + } + try { + $targetType = [System.Management.Automation.PSTypeName]::new("$($Parameter.Type)").Type + $convertedValue = if ($targetType) { + [System.Management.Automation.LanguagePrimitives]::ConvertTo($value, $targetType) + } + else { $value } + return [PSCustomObject]@{ Supplied = $true; Value = $convertedValue } + } + catch { + Write-IACTuiText -Text " Could not convert the value: $($_.Exception.Message)" -Style Warning + } + } +} + +function Read-IACTuiOperationParameters { + [CmdletBinding()] + param([Parameter(Mandatory)]$Operation) + + $sets = @($Operation.ParameterSets) + $parameterSet = $sets | Where-Object IsDefault | Select-Object -First 1 + if (-not $parameterSet) { $parameterSet = $sets | Select-Object -First 1 } + if ($sets.Count -gt 1) { + Write-IACTuiText -Text "`nParameter sets" -Style Accent + for ($index = 0; $index -lt $sets.Count; $index++) { + $suffix = if ($sets[$index].IsDefault) { ' (default)' } else { '' } + Write-Host " $($index + 1). $($sets[$index].Name)$suffix" + } + $selection = Read-Host "Select [1-$($sets.Count)] or Enter for default" + if ($selection -match '^\d+$' -and [int]$selection -ge 1 -and [int]$selection -le $sets.Count) { + $parameterSet = $sets[[int]$selection - 1] + } + } + + $values = @{} + foreach ($parameter in @($parameterSet.Parameters)) { + $result = Read-IACTuiParameterValue -Parameter $parameter -CommandName $Operation.Name + if ($result.Supplied) { $values[$parameter.Name] = $result.Value } + } + return $values +} + +function Show-IACTuiOperation { + [CmdletBinding()] + param([Parameter(Mandatory)]$Operation) + + try { + Clear-Host + Write-IACTuiText -Text $Operation.Name -Style Accent + Write-IACTuiText -Text $Operation.Synopsis -Style Muted + $parameters = Read-IACTuiOperationParameters -Operation $Operation + Write-IACTuiText -Text "`nRunning $($Operation.Name)...`n" -Style Success + & $Operation.Name @parameters | Out-Host + } + catch { + Write-IACTuiText -Text "`n$($_.Exception.Message)" -Style Error + } + Write-IACTuiText -Text "`nPress any key to return to the operation list." -Style Muted -NoNewline + $null = [Console]::ReadKey($true) +} + +function Show-IACTuiScreen { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object[]]$Operations, + [Parameter(Mandatory)][int]$SelectedIndex, + [AllowEmptyString()][string]$Filter = '' + ) + + Clear-Host + Write-IACTuiText -Text 'INTUNE ASSIGNMENT CHECKER 5.0' -Style Accent + $tenant = if ($script:CurrentTenantName) { $script:CurrentTenantName } elseif ($script:CurrentTenantId) { $script:CurrentTenantId } else { 'Not connected' } + Write-IACTuiText -Text "Tenant: $tenant | Filter: $(if ($Filter) { $Filter } else { '(none)' })" -Style Muted + Write-Host '' + + $height = try { [Console]::WindowHeight } catch { 30 } + $pageSize = [math]::Max(8, $height - 12) + $pageStart = [math]::Floor($SelectedIndex / $pageSize) * $pageSize + $pageEnd = [math]::Min($Operations.Count - 1, $pageStart + $pageSize - 1) + for ($index = $pageStart; $index -le $pageEnd; $index++) { + $operation = $Operations[$index] + $line = ' {0,-18} {1}' -f "[$($operation.Category)]", $operation.Name + if ($index -eq $SelectedIndex) { Write-IACTuiText -Text "> $line" -Style Selected } + else { Write-Host " $line" } + } + + if ($Operations.Count -gt 0) { + $selected = $Operations[$SelectedIndex] + Write-Host '' + Write-IACTuiText -Text $selected.Synopsis -Style Muted + Write-IACTuiText -Text "Capabilities: $(@($selected.Capabilities) -join ', ')" -Style Muted + } + Write-Host '' + Write-IACTuiText -Text '↑/↓ J/K navigate PgUp/PgDn jump Enter run / filter C/T switch tenant ? help Q quit' -Style Accent +} diff --git a/Module/IntuneAssignmentChecker/Public/Compare-IntuneAssignmentSnapshot.ps1 b/Module/IntuneAssignmentChecker/Public/Compare-IntuneAssignmentSnapshot.ps1 index 6e52981..fd6a78d 100644 --- a/Module/IntuneAssignmentChecker/Public/Compare-IntuneAssignmentSnapshot.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Compare-IntuneAssignmentSnapshot.ps1 @@ -46,32 +46,8 @@ function Compare-IntuneAssignmentSnapshot { if ("$($reference.Tenant.Id)" -cne "$($difference.Tenant.Id)") { throw "Snapshots belong to different tenants ('$($reference.Tenant.Id)' and '$($difference.Tenant.Id)')." } - $hasBlockingIncompleteCoverage = $false - foreach ($snapshot in @($reference, $difference)) { - if ([bool]$snapshot.Coverage.Complete) { continue } - - $snapshotSkippedCategories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) - $hasBlockingStatus = $false - foreach ($category in @($snapshot.Coverage.Categories)) { - if ($category.Status -ceq 'Skipped') { - [void]$snapshotSkippedCategories.Add("$($category.CategoryId)") - } - elseif ($category.Status -in @('Failed', 'Unknown')) { - $hasBlockingStatus = $true - } - } - $hasNonSkippedError = $false - foreach ($coverageError in @($snapshot.Coverage.Errors)) { - if (-not $snapshotSkippedCategories.Contains("$($coverageError.CategoryId)")) { - $hasNonSkippedError = $true - break - } - } - if ($hasBlockingStatus -or $hasNonSkippedError -or $snapshotSkippedCategories.Count -eq 0) { - $hasBlockingIncompleteCoverage = $true - break - } - } + $hasBlockingIncompleteCoverage = (Test-IACCoverageHasBlockingFailure -Coverage $reference.Coverage) -or + (Test-IACCoverageHasBlockingFailure -Coverage $difference.Coverage) if (-not $AllowIncompleteCoverage -and $hasBlockingIncompleteCoverage) { throw 'One or both snapshots have incomplete category coverage; use -AllowIncompleteCoverage to compare them explicitly.' } diff --git a/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 b/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 index 924c705..a588773 100644 --- a/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Connect-IntuneAssignmentChecker.ps1 @@ -21,7 +21,17 @@ function Connect-IntuneAssignmentChecker { [Parameter(Mandatory = $false, HelpMessage = "Environment (Global, USGov, USGovDoD)")] [ValidateSet("Global", "USGov", "USGovDoD")] - [string]$Environment = "Global" + [string]$Environment = "Global", + + [Parameter(Mandatory = $false, HelpMessage = "Capability profiles whose least-privilege permissions should be requested")] + [ValidateSet('Core', 'Applications', 'Devices', 'Scripts', 'CloudPC', 'ScopeTags', 'Audit', 'Full')] + [string[]]$Capability = @('Full'), + + [Parameter(Mandatory = $false)] + [switch]$SkipPermissionPrompt, + + [Parameter(Mandatory = $false)] + [switch]$PassThru ) # ── Banner ──────────────────────────────────────────────────────────── @@ -60,7 +70,7 @@ function Connect-IntuneAssignmentChecker { Write-Host "" } elseif ($local -gt $latest) { - Write-Host "Note: You are running a pre-release version ($localVersion)" -ForegroundColor Magenta + Write-Host "Note: The installed version ($localVersion) is newer than the current PSGallery version." -ForegroundColor Magenta Write-Host "" } } @@ -79,7 +89,11 @@ function Connect-IntuneAssignmentChecker { $parameterMode = $hasAppId -or $hasTenantId -or $hasClientSecret -or $hasClientSecretCredential -or $hasCertThumbprint -or $hasAccessToken # ── Required permissions ────────────────────────────────────────────── - $requiredPermissions = $script:RequiredPermissions + $script:RequestedCapabilities = @($Capability | Select-Object -Unique) + $requiredPermissions = if (Get-Command Resolve-IACCapabilityPermission -ErrorAction SilentlyContinue) { + @(Resolve-IACCapabilityPermission -Capability $script:RequestedCapabilities) + } + else { @($script:RequiredPermissions) } # ── Connect to Microsoft Graph ──────────────────────────────────────── try { @@ -239,7 +253,7 @@ function Connect-IntuneAssignmentChecker { Write-Host "The script will continue, but it may not function correctly without these permissions." -ForegroundColor Red Write-Host "Please ensure these permissions are granted to the app registration for full functionality." -ForegroundColor Yellow - $continueChoice = Read-Host "Do you want to continue anyway? (y/n)" + $continueChoice = if ($SkipPermissionPrompt) { 'y' } else { Read-Host "Do you want to continue anyway? (y/n)" } if ($continueChoice -notmatch '^[Yy]') { Write-Host "Connection cancelled by user." -ForegroundColor Red return @@ -262,8 +276,30 @@ function Connect-IntuneAssignmentChecker { } # ── Initialize scope tag lookup ─────────────────────────────────────── - $script:ScopeTagLookup = Get-ScopeTagLookup + $hasScopeTagCapability = $script:RequestedCapabilities -contains 'Full' -or $script:RequestedCapabilities -contains 'ScopeTags' + $script:ScopeTagLookup = if ($hasScopeTagCapability) { Get-ScopeTagLookup } else { @{} } # ── Initialize assignment filter lookup ─────────────────────────────── - $script:AssignmentFilterLookup = Get-AssignmentFilterLookup + $hasConfigurationCapability = $script:RequestedCapabilities -contains 'Full' -or + $script:RequestedCapabilities -contains 'Core' -or $script:RequestedCapabilities -contains 'Devices' + $script:AssignmentFilterLookup = if ($hasConfigurationCapability) { Get-AssignmentFilterLookup } else { @{} } + + $context = Get-MgContext -ErrorAction SilentlyContinue + $isAppOnly = $null -eq $context.Scopes -or @($context.Scopes).Count -eq 0 + if (Get-Command Get-IACCapabilityStatus -ErrorAction SilentlyContinue) { + $script:CapabilityStatus = @(Get-IACCapabilityStatus -GrantedPermission @($context.Scopes) -AppOnly:$isAppOnly) + } + if ($PassThru) { + $connection = [PSCustomObject][ordered]@{ + SchemaVersion = 1 + TenantId = $script:CurrentTenantId + TenantName = $script:CurrentTenantName + Account = $script:CurrentUserUPN + Environment = $script:GraphEnvironment + Authentication = if ($isAppOnly) { 'AppOnly' } else { 'Delegated' } + Capabilities = @($script:CapabilityStatus) + } + $connection.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.Connection') + $connection + } } diff --git a/Module/IntuneAssignmentChecker/Public/ConvertTo-IntuneAssignmentRecord.ps1 b/Module/IntuneAssignmentChecker/Public/ConvertTo-IntuneAssignmentRecord.ps1 new file mode 100644 index 0000000..7e10642 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/ConvertTo-IntuneAssignmentRecord.ps1 @@ -0,0 +1,23 @@ +function ConvertTo-IntuneAssignmentRecord { + <# + .SYNOPSIS + Migrates version 1 assignment records to the version 2 public schema. + + .DESCRIPTION + Validates and canonicalizes pipeline records. Version 1 and version 2 inputs + are accepted; output always has SchemaName, SchemaVersion 2, a deterministic + RecordId, and the explicit beta Graph API contract marker. + #> + [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentRecord')] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [object[]]$InputObject + ) + + process { + foreach ($record in @($InputObject)) { + if ($null -ne $record) { ConvertTo-IACSnapshotRecord -InputObject $record } + } + } +} diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentAccess.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentAccess.ps1 new file mode 100644 index 0000000..8db91a7 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentAccess.ps1 @@ -0,0 +1,122 @@ +function Get-IntuneAssignmentAccess { + <# + .SYNOPSIS + Explains which Intune RBAC role assignments can manage assignment records. + + .DESCRIPTION + Correlates role assignment members, resource scopes, and scope tags with live + or snapshotted policies. Results expose the granting role assignment and flag + orphaned or unexpectedly broad administrative boundaries. + + .PARAMETER SnapshotPath + Optional assignment snapshot used for policy and scope-tag inventory. + + .PARAMETER PolicyId + Optional policy IDs to limit the analysis. + #> + [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentAccess')] + param( + [Parameter()] + [string]$SnapshotPath, + + [Parameter()] + [string[]]$PolicyId = @(), + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [ValidateSet('Json', 'JsonLines', 'Csv')] + [string]$OutputFormat = 'Json' + ) + + if (-not $script:GraphEndpoint) { throw 'Connect first with Connect-IntuneAssignmentChecker.' } + $coverageStatus = 'Complete' + $coverageMessages = @() + $coverageBlocking = $false + $records = if ($SnapshotPath) { + $snapshot = Read-IACAssignmentSnapshot -Path $SnapshotPath + if (Test-IACCoverageHasBlockingFailure -Coverage $snapshot.Coverage) { + $coverageStatus = 'Partial' + $coverageBlocking = $true + $coverageMessages = @($snapshot.Coverage.Errors | ForEach-Object { "$($_.CategoryId): $($_.Message)" }) + } + elseif (-not $snapshot.Coverage.Complete) { + $coverageStatus = 'CompleteWithOptionalSkips' + $coverageMessages = @($snapshot.Coverage.Categories | Where-Object Status -EQ Skipped | ForEach-Object { "$($_.CategoryId): optional workload skipped" }) + } + @($snapshot.Records) + } + else { + $categories = @(Get-IntuneCategoryDefinition -Audience Effective) + $scan = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity {} -EntityCache @{} -BuildRecords + if (@($scan.Errors).Count -gt 0) { + $coverageStatus = 'Failed' + $coverageBlocking = $true + $coverageMessages = @($scan.Errors | ForEach-Object { "$($_.CategoryId): $($_.Message)" }) + } + elseif (@($scan.Skipped).Count -gt 0) { + $coverageStatus = 'CompleteWithOptionalSkips' + $coverageMessages = @($scan.Skipped | ForEach-Object { "$($_.CategoryId): $($_.Message)" }) + } + @($scan.Records) + } + if ($PolicyId.Count -gt 0) { $records = @($records | Where-Object PolicyId -In $PolicyId) } + $policies = @($records | Where-Object PolicyId | Group-Object PolicyId | ForEach-Object { $_.Group[0] }) + + $uri = '/deviceManagement/roleAssignments?$select=id,displayName,members,resourceScopes,roleScopeTagIds&$top=100' + $roleAssignments = @((Invoke-IACGraphRequest -Uri $uri -Method GET).value) + $roleDefinitions = @((Invoke-IACGraphRequest -Uri '/deviceManagement/roleDefinitions?$select=id,displayName,isBuiltIn&$expand=roleAssignments($select=id)&$top=100' -Method GET).value) + $results = foreach ($roleAssignment in $roleAssignments) { + $roleDefinition = $roleDefinitions | Where-Object { + @($_.roleAssignments.id) -contains $roleAssignment.id + } | Select-Object -First 1 + $members = @($roleAssignment.members | ForEach-Object { "$_" } | Where-Object { $_ }) + $resourceScopes = @($roleAssignment.resourceScopes | ForEach-Object { "$_" } | Where-Object { $_ }) + $scopeTags = @($roleAssignment.roleScopeTagIds | ForEach-Object { "$_" } | Where-Object { $_ }) + $isBroad = $resourceScopes.Count -eq 0 -and ($scopeTags.Count -eq 0 -or $scopeTags -contains '0') + $matchingPolicyInventory = @($policies | Where-Object { + $policyTags = @($_.ScopeTagIds | ForEach-Object { "$_" }) + $scopeTags.Count -eq 0 -or $scopeTags -contains '0' -or + @($policyTags | Where-Object { $_ -in $scopeTags }).Count -gt 0 + }) + $matchingPolicies = if ($isBroad) { @($null) } else { @($matchingPolicyInventory) } + if ($matchingPolicies.Count -eq 0) { $matchingPolicies = @($null) } + foreach ($policy in $matchingPolicies) { + $result = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentAccess' + SchemaVersion = 1 + TenantId = $script:CurrentTenantId + RoleAssignmentId = $roleAssignment.id + RoleAssignmentName = $roleAssignment.displayName + RoleDefinitionId = $roleDefinition.id + RoleDefinitionName = $roleDefinition.displayName + RoleDefinitionStatus = if ($roleDefinition) { 'Resolved' } else { 'Unavailable' } + Members = $members + ResourceScopes = $resourceScopes + RoleScopeTagIds = $scopeTags + PolicyId = $policy.PolicyId + PolicyName = $policy.PolicyName + CategoryId = $policy.CategoryId + PolicyScopeTagIds = @($policy.ScopeTagIds) + MatchingPolicyCount = $matchingPolicyInventory.Count + CoverageStatus = $coverageStatus + CoverageErrors = @($coverageMessages) + BoundaryStatus = if ($members.Count -eq 0) { 'Orphaned' } elseif ($isBroad) { 'Broad' } elseif ($policy) { 'InScope' } elseif ($coverageBlocking) { 'CoverageIncomplete' } else { 'NoMatchingPolicy' } + Explanation = if ($members.Count -eq 0) { + 'The role assignment has no members.' + } + elseif ($isBroad) { 'The role assignment has no resource-scope restriction and applies to all scope tags.' } + elseif ($policy) { 'The policy and role assignment share an Intune scope tag.' } + elseif ($coverageBlocking) { 'Policy coverage is incomplete, so the administrative boundary could not be fully evaluated.' } + else { 'No supplied policy shares this role assignment scope.' } + } + $result.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentAccess') + $result + } + } + $results = @($results) + if ($OutputPath) { $null = Export-IACStructuredOutput -InputObject $results -Path $OutputPath -Format $OutputFormat } + $results | Write-Output +} diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentDrift.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentDrift.ps1 new file mode 100644 index 0000000..32e250e --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentDrift.ps1 @@ -0,0 +1,194 @@ +function Get-IntuneAssignmentDrift { + <# + .SYNOPSIS + Captures or loads current assignment state and compares it with an approved baseline. + + .DESCRIPTION + Combines snapshot capture and comparison, classifies the risk of every change, + and can correlate changes with Intune audit events. Results can be emitted as + JSON, JSON Lines, or CSV and optionally posted to a webhook. No Intune settings + or assignments are modified. + + .PARAMETER BaselinePath + Approved reference snapshot. + + .PARAMETER CurrentSnapshotPath + Existing current snapshot. When omitted, the connected tenant is captured. + + .PARAMETER ApproveBaseline + Copies the validated current snapshot to BaselinePath instead of comparing it. + + .PARAMETER IncludeAuditAttribution + Queries beta Intune audit events and correlates actor and operation metadata. + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [OutputType('IntuneAssignmentChecker.AssignmentDriftEvent')] + param( + [Parameter(Mandatory)] + [string]$BaselinePath, + + [Parameter()] + [string]$CurrentSnapshotPath, + + [Parameter()] + [switch]$ApproveBaseline, + + [Parameter()] + [switch]$Force, + + [Parameter()] + [switch]$AllowIncompleteCoverage, + + [Parameter()] + [switch]$AllowCoverageMismatch, + + [Parameter()] + [switch]$IncludeAuditAttribution, + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [ValidateSet('Json', 'JsonLines', 'Csv')] + [string]$OutputFormat = 'JsonLines', + + [Parameter()] + [uri]$WebhookUri, + + [Parameter()] + [ValidateSet('None', 'Low', 'Medium', 'High', 'Critical')] + [string]$FailOnRisk = 'None', + + [Parameter()] + [switch]$SetExitCode + ) + + $temporarySnapshot = $null + try { + if (-not $CurrentSnapshotPath) { + if (-not $script:GraphEndpoint) { throw 'Connect first with Connect-IntuneAssignmentChecker or supply -CurrentSnapshotPath.' } + $temporarySnapshot = Join-Path ([IO.Path]::GetTempPath()) "iac-current-$([guid]::NewGuid().ToString('N')).json" + $null = Export-IntuneAssignmentSnapshot -Path $temporarySnapshot -Force -PassThru + $CurrentSnapshotPath = $temporarySnapshot + } + $current = Read-IACAssignmentSnapshot -Path $CurrentSnapshotPath + + if ($ApproveBaseline) { + $resolvedBaseline = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($BaselinePath) + if ((Test-Path -LiteralPath $resolvedBaseline) -and -not $Force) { + throw "Baseline '$resolvedBaseline' already exists; use -Force to replace it." + } + $parent = Split-Path -Parent $resolvedBaseline + if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } + Copy-Item -LiteralPath $CurrentSnapshotPath -Destination $resolvedBaseline -Force + $approval = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.BaselineApproval' + SchemaVersion = 1 + BaselinePath = $resolvedBaseline + TenantId = $current.Tenant.Id + CapturedAtUtc = $current.CapturedAtUtc + ApprovedAtUtc = [datetimeoffset]::UtcNow.ToString('o') + RecordCount = @($current.Records).Count + Complete = -not (Test-IACCoverageHasBlockingFailure -Coverage $current.Coverage) + } + $approval.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.BaselineApproval') + return $approval + } + + $reference = Read-IACAssignmentSnapshot -Path $BaselinePath + $compareParameters = @{ + ReferencePath = $BaselinePath + DifferencePath = $CurrentSnapshotPath + AllowIncompleteCoverage = $AllowIncompleteCoverage + AllowCoverageMismatch = $AllowCoverageMismatch + } + $changes = @(Compare-IntuneAssignmentSnapshot @compareParameters) + $auditEvents = @() + if ($IncludeAuditAttribution) { + if (-not $script:GraphEndpoint) { throw 'Audit attribution requires an active Microsoft Graph connection.' } + $from = [datetimeoffset]::Parse("$($reference.CapturedAtUtc)").UtcDateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') + $to = [datetimeoffset]::Parse("$($current.CapturedAtUtc)").UtcDateTime.AddMinutes(5).ToString('yyyy-MM-ddTHH:mm:ssZ') + $auditUri = "/deviceManagement/auditEvents?`$filter=activityDateTime ge $from and activityDateTime le $to&`$orderby=activityDateTime desc&`$select=id,displayName,activityDateTime,actor,resources,activityOperationType,componentName&`$top=100" + $auditEvents = @((Invoke-IACGraphRequest -Uri $auditUri -Method GET).value) + } + + $events = foreach ($change in $changes) { + $state = if ($change.After) { $change.After } else { $change.Before } + $audit = $auditEvents | Where-Object { + $candidateAuditEvent = $_ + @($candidateAuditEvent.resources | Where-Object { + $_.resourceId -eq $change.PolicyId -or $_.resourceId -eq $change.AssignmentId -or + ($state.PolicyName -and $_.displayName -eq $state.PolicyName) + }).Count -gt 0 + } | Sort-Object activityDateTime -Descending | Select-Object -First 1 + $risk = if ($change.ChangeType -eq 'Added' -and $change.After.TargetType -in @('AllUsers', 'AllDevices') -and $change.After.Intent -eq 'required') { 'Critical' } + elseif ($change.ChangeType -eq 'Added' -and $change.After.TargetType -in @('AllUsers', 'AllDevices')) { 'High' } + elseif ($change.ChangeType -eq 'Removed') { 'High' } + elseif ($change.ChangedFields -contains 'AssignmentMode' -or $change.ChangedFields -contains 'TargetType') { 'High' } + elseif ($change.ChangedFields -contains 'FilterRule' -or $change.ChangedFields -contains 'FilterId') { 'Medium' } + else { 'Low' } + $actor = if ($audit.actor) { + if ($audit.actor.userPrincipalName) { $audit.actor.userPrincipalName } + elseif ($audit.actor.applicationDisplayName) { $audit.actor.applicationDisplayName } + elseif ($audit.actor.applicationId) { $audit.actor.applicationId } + else { $audit.actor.type } + } + $driftEvent = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentDriftEvent' + SchemaVersion = 1 + TenantId = $current.Tenant.Id + TenantName = $current.Tenant.Name + BaselineCaptured = $reference.CapturedAtUtc + CurrentCaptured = $current.CapturedAtUtc + ChangeType = $change.ChangeType + Risk = $risk + IdentityKey = $change.IdentityKey + CategoryId = $change.CategoryId + PolicyId = $change.PolicyId + PolicyName = $state.PolicyName + AssignmentId = $change.AssignmentId + ChangedFields = @($change.ChangedFields) + Before = $change.Before + After = $change.After + AuditEventId = $audit.id + AuditOperation = if ($audit.displayName) { $audit.displayName } else { $audit.activityOperationType } + AuditActor = $actor + AuditActivityUtc = $audit.activityDateTime + Attribution = if ($audit) { 'Correlated' } elseif ($IncludeAuditAttribution) { 'NotFound' } else { 'NotRequested' } + } + $driftEvent.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentDriftEvent') + $driftEvent + } + + $events = @($events) + if ($OutputPath) { $null = Export-IACStructuredOutput -InputObject $events -Path $OutputPath -Format $OutputFormat } + if ($WebhookUri) { + if (-not $WebhookUri.IsAbsoluteUri -or $WebhookUri.Scheme -cne 'https') { + throw '-WebhookUri must be an absolute HTTPS URI.' + } + $payload = [PSCustomObject]@{ + schemaName = 'IntuneAssignmentChecker.AssignmentDriftBatch' + schemaVersion = 1 + tenantId = $current.Tenant.Id + generatedAtUtc = [datetimeoffset]::UtcNow.ToString('o') + events = $events + } | ConvertTo-Json -Depth 30 + if ($PSCmdlet.ShouldProcess($WebhookUri.AbsoluteUri, 'Post assignment drift batch')) { + $null = Invoke-RestMethod -Uri $WebhookUri -Method Post -ContentType 'application/json' -Body $payload -MaximumRedirection 0 -ErrorAction Stop + } + } + $events | Write-Output + + $threshold = Get-IACSeverityRank -Severity $FailOnRisk + $hasBlocking = $threshold -gt 0 -and @($events | Where-Object { + (Get-IACSeverityRank -Severity $_.Risk) -ge $threshold + }).Count -gt 0 + if ($SetExitCode) { $global:LASTEXITCODE = if ($hasBlocking) { 2 } else { 0 } } + if ($hasBlocking) { throw "Assignment drift met or exceeded the configured $FailOnRisk risk threshold." } + } + finally { + if ($temporarySnapshot -and (Test-Path -LiteralPath $temporarySnapshot)) { + Remove-Item -LiteralPath $temporarySnapshot -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentHealth.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentHealth.ps1 new file mode 100644 index 0000000..299a753 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentHealth.ps1 @@ -0,0 +1,137 @@ +function Get-IntuneAssignmentHealth { + <# + .SYNOPSIS + Correlates assignment targeting with policy and application delivery status. + + .DESCRIPTION + Uses workload-specific beta adapters for device configuration, compliance, and + applications. Status and coverage records are returned together so an unavailable + or tenant-failing endpoint is never mistaken for healthy deployment coverage. + + .PARAMETER Workload + Workload adapters to run. + + .PARAMETER StaleAfter + Age after which a reporting timestamp is classified as stale. + #> + [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentHealth')] + param( + [Parameter()] + [ValidateSet('DeviceConfiguration', 'Compliance', 'Applications')] + [string[]]$Workload = @('DeviceConfiguration', 'Compliance', 'Applications'), + + [Parameter()] + [string[]]$PolicyId = @(), + + [Parameter()] + [ValidateRange(1, 3650)] + [int]$StaleAfterDays = 14, + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [ValidateSet('Json', 'JsonLines', 'Csv')] + [string]$OutputFormat = 'JsonLines' + ) + + if (-not $script:GraphEndpoint) { throw 'Connect first with Connect-IntuneAssignmentChecker.' } + $staleAfter = [timespan]::FromDays($StaleAfterDays) + $results = [System.Collections.Generic.List[object]]::new() + + if ($Workload -contains 'DeviceConfiguration') { + try { + $policies = @(Get-IntuneEntities -EntityType deviceConfigurations -ThrowOnError) + if ($PolicyId.Count -gt 0) { $policies = @($policies | Where-Object id -In $PolicyId) } + foreach ($policy in $policies) { + $skip = 0 + $pageCount = 0 + $seenRows = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + do { + $pageCount++ + if ($pageCount -gt 1000) { throw "Device configuration health paging exceeded 1000 pages for policy '$($policy.id)'." } + $body = @{ + filter = "(PolicyBaseTypeName eq 'Microsoft.Management.Services.Api.DeviceConfiguration') and (PolicyId eq '$($policy.id)')" + select = @('DeviceName', 'UPN', 'PolicyStatus', 'PspdpuLastModifiedTimeUtc', 'IntuneDeviceId') + skip = $skip + top = 100 + } | ConvertTo-Json -Depth 10 + $report = ConvertFrom-IACReportResponse -Response (Invoke-IACGraphRequest -Uri '/deviceManagement/reports/getConfigurationPolicyDevicesReport' -Method POST -Body $body) + $rows = @(ConvertFrom-IACReportRows -Report $report) + $newRows = @($rows | Where-Object { + $rowKey = @( + ConvertTo-IACIdentityComponent $_.IntuneDeviceId + ConvertTo-IACIdentityComponent $_.DeviceName + ConvertTo-IACIdentityComponent $_.UPN + ConvertTo-IACIdentityComponent $_.PolicyStatus + ConvertTo-IACIdentityComponent $_.PspdpuLastModifiedTimeUtc + ) -join '|' + $seenRows.Add($rowKey) + }) + if ($newRows.Count -ne $rows.Count) { + throw "Device configuration health paging returned repeated device rows for policy '$($policy.id)'." + } + foreach ($row in $newRows) { + [void]$results.Add((New-IACAssignmentHealthRecord -Workload DeviceConfiguration -PolicyId "$($policy.id)" -PolicyName "$($policy.displayName)" -DeviceId "$($row.IntuneDeviceId)" -DeviceName "$($row.DeviceName)" -UserPrincipalName "$($row.UPN)" -RawStatus $row.PolicyStatus -LastReportedDateTime $row.PspdpuLastModifiedTimeUtc -StaleAfter $staleAfter)) + } + $skip += $rows.Count + } while ($rows.Count -eq 100) + } + [void]$results.Add((New-IACAssignmentHealthCoverage -Workload DeviceConfiguration -Status Complete -Message "Processed $($policies.Count) policies.")) + } + catch { + [void]$results.Add((New-IACAssignmentHealthCoverage -Workload DeviceConfiguration -Status Failed -Message $_.Exception.Message)) + } + } + + if ($Workload -contains 'Compliance') { + try { + $policies = @(Get-IntuneEntities -EntityType deviceCompliancePolicies -ThrowOnError) + if ($PolicyId.Count -gt 0) { $policies = @($policies | Where-Object id -In $PolicyId) } + $failureCount = 0 + foreach ($policy in $policies) { + try { + $statuses = @((Invoke-IACGraphRequest -Uri "/deviceManagement/deviceCompliancePolicies('$($policy.id)')/deviceStatuses?`$select=id,deviceDisplayName,userPrincipalName,status,lastReportedDateTime&`$top=100" -Method GET).value) + foreach ($status in $statuses) { + [void]$results.Add((New-IACAssignmentHealthRecord -Workload Compliance -PolicyId "$($policy.id)" -PolicyName "$($policy.displayName)" -DeviceId "$($status.id)" -DeviceName "$($status.deviceDisplayName)" -UserPrincipalName "$($status.userPrincipalName)" -RawStatus $status.status -LastReportedDateTime $status.lastReportedDateTime -StaleAfter $staleAfter)) + } + } + catch { $failureCount++; Write-Warning "Compliance status unavailable for '$($policy.displayName)': $($_.Exception.Message)" } + } + $coverageStatus = if ($failureCount -eq 0) { 'Complete' } elseif ($failureCount -eq $policies.Count) { 'Failed' } else { 'Partial' } + [void]$results.Add((New-IACAssignmentHealthCoverage -Workload Compliance -Status $coverageStatus -Message "Processed $($policies.Count - $failureCount) of $($policies.Count) policies; $failureCount endpoint failure(s).")) + } + catch { + [void]$results.Add((New-IACAssignmentHealthCoverage -Workload Compliance -Status Failed -Message $_.Exception.Message)) + } + } + + if ($Workload -contains 'Applications') { + try { + $apps = @((Invoke-IACGraphRequest -Uri '/deviceAppManagement/mobileApps?$select=id,displayName,isAssigned&$top=100' -Method GET).value | Where-Object isAssigned) + if ($PolicyId.Count -gt 0) { $apps = @($apps | Where-Object id -In $PolicyId) } + $failureCount = 0 + foreach ($app in $apps) { + try { + $statuses = @((Invoke-IACGraphRequest -Uri "/deviceAppManagement/mobileApps/$($app.id)/deviceStatuses?`$select=id,deviceName,deviceId,lastSyncDateTime,mobileAppInstallStatusValue,installState,installStateDetail,errorCode,userPrincipalName&`$top=100" -Method GET).value) + foreach ($status in $statuses) { + $rawStatus = if ($status.installState) { $status.installState } else { $status.mobileAppInstallStatusValue } + $detail = @($status.installStateDetail, $(if ($status.errorCode) { "ErrorCode=$($status.errorCode)" }) | Where-Object { $_ }) -join '; ' + [void]$results.Add((New-IACAssignmentHealthRecord -Workload Applications -PolicyId "$($app.id)" -PolicyName "$($app.displayName)" -DeviceId "$($status.deviceId)" -DeviceName "$($status.deviceName)" -UserPrincipalName "$($status.userPrincipalName)" -RawStatus $rawStatus -Detail $detail -LastReportedDateTime $status.lastSyncDateTime -StaleAfter $staleAfter)) + } + } + catch { $failureCount++; Write-Warning "Application status unavailable for '$($app.displayName)': $($_.Exception.Message)" } + } + $coverageStatus = if ($failureCount -eq 0) { 'Complete' } elseif ($failureCount -eq $apps.Count) { 'Failed' } else { 'Partial' } + [void]$results.Add((New-IACAssignmentHealthCoverage -Workload Applications -Status $coverageStatus -Message "Processed $($apps.Count - $failureCount) of $($apps.Count) assigned applications; $failureCount endpoint failure(s).")) + } + catch { + [void]$results.Add((New-IACAssignmentHealthCoverage -Workload Applications -Status Failed -Message $_.Exception.Message)) + } + } + + $ordered = @($results | Sort-Object Workload, RecordType, PolicyName, DeviceName) + if ($OutputPath) { $null = Export-IACStructuredOutput -InputObject $ordered -Path $OutputPath -Format $OutputFormat } + $ordered | Write-Output +} diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentOperation.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentOperation.ps1 new file mode 100644 index 0000000..8bbb518 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentOperation.ps1 @@ -0,0 +1,32 @@ +function Get-IntuneAssignmentOperation { + <# + .SYNOPSIS + Returns the operation catalog used by the terminal UI. + + .DESCRIPTION + Discovers every exported operational command and returns structured metadata + for its help, capabilities, parameter sets, parameters, and validation choices. + The terminal UI consumes this catalog directly, which keeps it in parity with + the PowerShell module without a second command implementation. + + .PARAMETER Name + Optional wildcard pattern used to filter command names. + + .PARAMETER Category + Optional operation category filter. + #> + [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.OperationDescriptor')] + param( + [Parameter(Position = 0)] + [SupportsWildcards()] + [string]$Name = '*', + + [Parameter()] + [string]$Category + ) + + Get-IACOperationCatalog | Where-Object { + $_.Name -like $Name -and (-not $Category -or $_.Category -eq $Category) + } +} diff --git a/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentChecker.ps1 b/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentChecker.ps1 index 336c6d1..48b9a09 100644 --- a/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentChecker.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentChecker.ps1 @@ -171,17 +171,19 @@ function Invoke-IntuneAssignmentChecker { return } - # ── Main loop ───────────────────────────────────────────────────────── - do { - if (-not $parameterMode) { - Show-Menu - $selection = Read-Host - } - else { - $selection = $selectedOption - } + # The v5 interactive surface is generated from the exported command catalog. + # Keep the legacy feature switches below for non-interactive compatibility, + # but route the alias/default invocation to the full-parity terminal UI. + if (-not $parameterMode) { + Start-IntuneAssignmentCheckerTui + return + } - switch ($selection) { + # The legacy feature switches remain one-shot compatibility entry points. + # Interactive navigation and tenant switching now live in the exported TUI. + $selection = $selectedOption + + switch ($selection) { '1' { Get-IntuneUserAssignment ` -UserPrincipalNames $UserPrincipalNames ` @@ -292,42 +294,5 @@ function Invoke-IntuneAssignmentChecker { -ExportPath $ExportPath ` -ScopeTagFilter $ScopeTagFilter } - {$_ -eq 'T' -or $_ -eq 't'} { - Switch-Tenant - } - '0' { - Write-Host "Disconnecting from Microsoft Graph..." -ForegroundColor Yellow - Disconnect-MgGraph | Out-Null - Write-Host "Thank you for using IntuneAssignmentChecker!" -ForegroundColor Green - Write-Host "If you found this tool helpful, please consider:" -ForegroundColor Cyan - Write-Host "- Starring the repository: https://github.com/ugurkocde/IntuneAssignmentChecker" -ForegroundColor White - Write-Host "- Supporting the project: https://github.com/sponsors/ugurkocde" -ForegroundColor White - Write-Host "" - return - } - '98' { - Write-Host "Opening GitHub Sponsor Page ..." -ForegroundColor Green - Start-Process "https://github.com/sponsors/ugurkocde" - } - '99' { - Write-Host "Opening GitHub Repository..." -ForegroundColor Green - Start-Process "https://github.com/ugurkocde/IntuneAssignmentChecker" - } - default { - Write-Host "Invalid choice, please select 1-16, T, 98, 99, or 0." -ForegroundColor Red - } - } - - # In parameter mode, exit after completing the task - # In interactive mode, return to the menu unless exit was selected - if ($selection -ne '0') { - if ($parameterMode) { - break - } - else { - Write-Host "Press any key to return to the main menu..." -ForegroundColor Cyan - $null = $host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") - } } - } while ($selection -ne '0') } diff --git a/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentFleetScan.ps1 b/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentFleetScan.ps1 new file mode 100644 index 0000000..bdecf74 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentFleetScan.ps1 @@ -0,0 +1,167 @@ +function Invoke-IntuneAssignmentFleetScan { + <# + .SYNOPSIS + Runs an isolated assignment-governance scan across multiple tenants. + + .DESCRIPTION + Reads tenant authentication and capability profiles from JSON, processes each + tenant sequentially because Graph authentication is process-global, and continues + after tenant-specific failures. Client secrets are read from named environment + variables and are never accepted in the configuration file itself. + + .PARAMETER ConfigurationPath + JSON file containing a tenants array. Each tenant requires TenantId and supports + AppId, CertificateThumbprint, ClientSecretEnvironmentVariable, Environment, + Capability, BaselinePath, RulePath, and WaiverPath. + #> + [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.FleetTenantResult')] + param( + [Parameter(Mandatory)] + [string]$ConfigurationPath, + + [Parameter()] + [string]$OutputDirectory, + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [ValidateSet('Json', 'JsonLines', 'Csv')] + [string]$OutputFormat = 'JsonLines' + ) + + $resolvedConfiguration = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ConfigurationPath) + $configuration = Get-Content -LiteralPath $resolvedConfiguration -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 30 -ErrorAction Stop + if ([int]$configuration.schemaVersion -ne 1) { throw "Fleet configuration schema '$($configuration.schemaVersion)' is not supported." } + if (@($configuration.tenants).Count -eq 0) { throw 'Fleet configuration contains no tenants.' } + $duplicateTenant = @($configuration.tenants | Group-Object TenantId | Where-Object Count -gt 1) + if ($duplicateTenant.Count -gt 0) { throw "Fleet configuration contains duplicate tenant IDs: $($duplicateTenant.Name -join ', ')." } + + $resolvedOutputDirectory = if ($OutputDirectory) { + $path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory) + if (-not (Test-Path -LiteralPath $path)) { New-Item -ItemType Directory -Path $path -Force | Out-Null } + $path + } + $fleetResults = [System.Collections.Generic.List[object]]::new() + foreach ($tenant in @($configuration.tenants)) { + $started = [datetimeoffset]::UtcNow + $tenantId = "$($tenant.TenantId)" + $snapshotPath = $null + $governancePath = $null + try { + if ([string]::IsNullOrWhiteSpace($tenantId)) { throw 'Every fleet tenant requires TenantId.' } + if (-not $tenant.CertificateThumbprint -and -not $tenant.ClientSecretEnvironmentVariable) { + throw 'Unattended fleet tenants require CertificateThumbprint or ClientSecretEnvironmentVariable.' + } + if ([string]::IsNullOrWhiteSpace("$($tenant.AppId)")) { + throw 'Unattended fleet tenants require AppId.' + } + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + $script:GraphEndpoint = $null + $script:CurrentTenantId = $null + $script:CurrentTenantName = $null + $script:CurrentUserUPN = $null + $script:AssignmentFilterLookup = $null + $script:ScopeTagLookup = $null + $script:GroupInfoCache = $null + + $connect = @{ + TenantId = $tenantId + Environment = if ($tenant.Environment) { "$($tenant.Environment)" } else { 'Global' } + Capability = if ($tenant.Capability) { @($tenant.Capability) } else { @('Full') } + SkipPermissionPrompt = $true + PassThru = $true + } + if ($tenant.AppId) { $connect.AppId = "$($tenant.AppId)" } + if ($tenant.CertificateThumbprint) { $connect.CertificateThumbprint = "$($tenant.CertificateThumbprint)" } + if ($tenant.ClientSecretEnvironmentVariable) { + if (-not $tenant.AppId) { throw 'ClientSecretEnvironmentVariable requires AppId.' } + $secret = [Environment]::GetEnvironmentVariable("$($tenant.ClientSecretEnvironmentVariable)") + if ([string]::IsNullOrWhiteSpace($secret)) { throw "Environment variable '$($tenant.ClientSecretEnvironmentVariable)' is empty or unavailable." } + $secureSecret = ConvertTo-SecureString $secret -AsPlainText -Force + $connect.ClientSecretCredential = [PSCredential]::new("$($tenant.AppId)", $secureSecret) + $secret = $null + } + $connection = Connect-IntuneAssignmentChecker @connect + if (-not $connection -or -not $script:GraphEndpoint) { throw 'Microsoft Graph connection did not complete.' } + if ($connection.TenantId -and $connection.TenantId -ne $tenantId) { + throw "Connected tenant '$($connection.TenantId)' does not match configured tenant '$tenantId'." + } + + if ($resolvedOutputDirectory) { + $safeTenantId = $tenantId -replace '[^A-Za-z0-9._-]', '_' + $snapshotPath = Join-Path $resolvedOutputDirectory "$safeTenantId.snapshot.json" + $governancePath = Join-Path $resolvedOutputDirectory "$safeTenantId.governance.jsonl" + $null = Export-IntuneAssignmentSnapshot -Path $snapshotPath -Force -PassThru + $governanceArguments = @{ SnapshotPath = $snapshotPath; OutputPath = $governancePath; OutputFormat = 'JsonLines' } + } + else { $governanceArguments = @{} } + if ($tenant.RulePath) { $governanceArguments.RulePath = "$($tenant.RulePath)" } + elseif ($configuration.RulePath) { $governanceArguments.RulePath = "$($configuration.RulePath)" } + if ($tenant.WaiverPath) { $governanceArguments.WaiverPath = "$($tenant.WaiverPath)" } + elseif ($configuration.WaiverPath) { $governanceArguments.WaiverPath = "$($configuration.WaiverPath)" } + $findings = @(Test-IntuneAssignmentGovernance @governanceArguments) + $drift = @() + if ($tenant.BaselinePath -and $snapshotPath -and (Test-Path -LiteralPath "$($tenant.BaselinePath)")) { + $drift = @(Get-IntuneAssignmentDrift -BaselinePath "$($tenant.BaselinePath)" -CurrentSnapshotPath $snapshotPath) + } + $result = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.FleetTenantResult' + SchemaVersion = 1 + TenantId = $tenantId + TenantName = $connection.TenantName + Status = if (@($findings | Where-Object Severity -in @('Critical', 'High')).Count -gt 0) { 'CompletedWithFindings' } else { 'Completed' } + StartedAtUtc = $started.ToString('o') + CompletedAtUtc = [datetimeoffset]::UtcNow.ToString('o') + Capabilities = @($connection.Capabilities) + FindingCount = $findings.Count + CriticalCount = @($findings | Where-Object Severity -EQ 'Critical').Count + HighCount = @($findings | Where-Object Severity -EQ 'High').Count + DriftCount = $drift.Count + SnapshotPath = $snapshotPath + GovernancePath = $governancePath + Findings = @($findings) + DriftEvents = @($drift) + GraphApiVersion = 'beta' + Error = $null + } + } + catch { + $result = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.FleetTenantResult' + SchemaVersion = 1 + TenantId = $tenantId + TenantName = $null + Status = 'Failed' + StartedAtUtc = $started.ToString('o') + CompletedAtUtc = [datetimeoffset]::UtcNow.ToString('o') + Capabilities = @() + FindingCount = 0 + CriticalCount = 0 + HighCount = 0 + DriftCount = 0 + SnapshotPath = $snapshotPath + GovernancePath = $governancePath + Findings = @() + DriftEvents = @() + GraphApiVersion = 'beta' + Error = $_.Exception.Message + } + Write-Error -ErrorRecord $_ -ErrorAction Continue + } + $result.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.FleetTenantResult') + [void]$fleetResults.Add($result) + } + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + $script:GraphEndpoint = $null + $script:CurrentTenantId = $null + $script:CurrentTenantName = $null + $script:CurrentUserUPN = $null + $script:TemplateIdToFamilyCache = $null + $script:AssignmentFilterLookup = $null + $script:ScopeTagLookup = $null + $script:GroupInfoCache = $null + if ($OutputPath) { $null = Export-IACStructuredOutput -InputObject @($fleetResults) -Path $OutputPath -Format $OutputFormat } + $fleetResults | Write-Output +} diff --git a/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentScan.ps1 b/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentScan.ps1 new file mode 100644 index 0000000..a19ca86 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentScan.ps1 @@ -0,0 +1,217 @@ +function Invoke-IntuneAssignmentScan { + <# + .SYNOPSIS + Runs the shared Intune assignment provider registry with resumable checkpoints. + + .DESCRIPTION + Produces one structured scan-run object containing canonical assignment records, + coverage, errors, skips, and performance diagnostics. A checkpoint is written + after every category, allowing an interrupted scan to resume without repeating + completed workloads. The command is read-only. + + .PARAMETER Category + Optional category IDs from the Effective provider registry. The default is every + registered assignment category. + + .PARAMETER ScanBudgetSeconds + Maximum wall-clock budget. The budget is checked between categories so a Graph + request that is already in progress is allowed to finish safely. + + .PARAMETER CheckpointPath + JSON checkpoint path. The file contains completed records and coverage metadata, + but no credentials or access tokens. + + .PARAMETER Resume + Resumes the exact category selection stored in CheckpointPath. + + .PARAMETER KeepCheckpoint + Retains a completed checkpoint. Incomplete checkpoints are always retained. + #> + [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentScanRun')] + param( + [Parameter()] + [string[]]$Category = @(), + + [Parameter()] + [ValidateRange(0, 86400)] + [int]$ScanBudgetSeconds = 0, + + [Parameter()] + [string]$CheckpointPath, + + [Parameter()] + [switch]$Resume, + + [Parameter()] + [switch]$KeepCheckpoint, + + [Parameter()] + [switch]$ShowProgress + ) + + if (-not $script:GraphEndpoint) { + throw 'Connect first with Connect-IntuneAssignmentChecker.' + } + if ($Resume -and -not $CheckpointPath) { + throw '-Resume requires -CheckpointPath.' + } + + $registry = @(Get-IntuneCategoryDefinition -Audience Effective) + $knownIds = @($registry.Id) + $selectedIds = if ($Category.Count -gt 0) { @($Category | Select-Object -Unique) } else { @($knownIds) } + $unknown = @($selectedIds | Where-Object { $_ -notin $knownIds }) + if ($unknown.Count -gt 0) { + throw "Unknown scan category: $($unknown -join ', '). Use Get-IntuneAssignmentOperation for command discovery." + } + + $runId = [guid]::NewGuid().ToString('D') + $startedAt = [datetimeoffset]::UtcNow + $completedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $records = [System.Collections.Generic.List[object]]::new() + $coverage = [System.Collections.Generic.List[object]]::new() + $errors = [System.Collections.Generic.List[object]]::new() + $skipped = [System.Collections.Generic.List[object]]::new() + $resolvedCheckpoint = $null + + if ($CheckpointPath) { + if ([IO.Path]::GetExtension($CheckpointPath) -ine '.json') { throw 'CheckpointPath must be a .json file.' } + $resolvedCheckpoint = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CheckpointPath) + if ($Resume) { + if (-not (Test-Path -LiteralPath $resolvedCheckpoint -PathType Leaf)) { + throw "Checkpoint '$resolvedCheckpoint' does not exist." + } + $checkpoint = Get-Content -LiteralPath $resolvedCheckpoint -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 50 -ErrorAction Stop + if ($checkpoint.SchemaName -ne 'IntuneAssignmentChecker.AssignmentScanCheckpoint' -or [int]$checkpoint.SchemaVersion -ne 1) { + throw "Checkpoint '$resolvedCheckpoint' does not use the supported assignment scan checkpoint schema." + } + if (@(Compare-Object -ReferenceObject @($checkpoint.SelectedCategories) -DifferenceObject @($selectedIds)).Count -gt 0 -and $PSBoundParameters.ContainsKey('Category')) { + throw 'The requested categories do not match the checkpoint category selection.' + } + $selectedIds = @($checkpoint.SelectedCategories) + $runId = "$($checkpoint.RunId)" + $styles = [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal + $startedAt = [datetimeoffset]::Parse("$($checkpoint.StartedAtUtc)", [Globalization.CultureInfo]::InvariantCulture, $styles) + foreach ($id in @($checkpoint.CompletedCategories)) { [void]$completedIds.Add("$id") } + foreach ($record in @($checkpoint.Records)) { [void]$records.Add((ConvertTo-IntuneAssignmentRecord -InputObject $record)) } + foreach ($item in @($checkpoint.Coverage)) { [void]$coverage.Add($item) } + foreach ($item in @($checkpoint.Errors)) { [void]$errors.Add($item) } + foreach ($item in @($checkpoint.Skipped)) { [void]$skipped.Add($item) } + } + elseif (Test-Path -LiteralPath $resolvedCheckpoint) { + throw "Checkpoint '$resolvedCheckpoint' already exists; use -Resume or choose another path." + } + } + + $saveCheckpoint = { + if (-not $resolvedCheckpoint) { return } + $parent = Split-Path -Parent $resolvedCheckpoint + if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } + $document = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentScanCheckpoint' + SchemaVersion = 1 + RunId = $runId + StartedAtUtc = $startedAt.ToUniversalTime().ToString('o') + UpdatedAtUtc = [datetimeoffset]::UtcNow.ToString('o') + TenantId = $script:CurrentTenantId + SelectedCategories = @($selectedIds) + CompletedCategories = @($selectedIds | Where-Object { $completedIds.Contains($_) }) + Records = @($records) + Coverage = @($coverage) + Errors = @($errors) + Skipped = @($skipped) + } + $temporaryPath = "$resolvedCheckpoint.tmp" + [IO.File]::WriteAllText($temporaryPath, ($document | ConvertTo-Json -Depth 50), [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporaryPath -Destination $resolvedCheckpoint -Force + } + + $entityCache = @{} + $budgetExceeded = $false + $categories = @($registry | Where-Object { $_.Id -in $selectedIds }) + foreach ($categoryDefinition in $categories) { + if ($completedIds.Contains($categoryDefinition.Id)) { continue } + $elapsed = [datetimeoffset]::UtcNow - $startedAt + if ($ScanBudgetSeconds -gt 0 -and $elapsed.TotalSeconds -ge $ScanBudgetSeconds) { + $budgetExceeded = $true + break + } + + # A resumed failed category replaces its previous failure/coverage entry. + for ($index = $coverage.Count - 1; $index -ge 0; $index--) { + if ($coverage[$index].CategoryId -eq $categoryDefinition.Id) { $coverage.RemoveAt($index) } + } + for ($index = $errors.Count - 1; $index -ge 0; $index--) { + if ($errors[$index].CategoryId -eq $categoryDefinition.Id) { $errors.RemoveAt($index) } + } + + $categoryStarted = [datetimeoffset]::UtcNow + $scan = Invoke-IntuneCategoryScan -Categories @($categoryDefinition) -ProcessEntity {} ` + -EntityCache $entityCache -BuildRecords -ShowProgress:$ShowProgress -ProgressVerb 'Scanning' + foreach ($record in @($scan.Records)) { [void]$records.Add($record) } + foreach ($item in @($scan.Errors)) { + [void]$errors.Add([PSCustomObject][ordered]@{ + CategoryId = "$($item.CategoryId)" + DisplayName = "$($item.DisplayName)" + Message = "$($item.Message)" + }) + } + foreach ($item in @($scan.Skipped)) { + [void]$skipped.Add([PSCustomObject][ordered]@{ + CategoryId = "$($item.CategoryId)" + DisplayName = "$($item.DisplayName)" + Message = "$($item.Message)" + }) + } + $status = if (@($scan.Errors).Count -gt 0) { 'Failed' } elseif (@($scan.Skipped).Count -gt 0) { 'Skipped' } else { 'Captured' } + [void]$coverage.Add([PSCustomObject][ordered]@{ + CategoryId = $categoryDefinition.Id + DisplayName = $categoryDefinition.DisplayName + Status = $status + RecordCount = @($scan.Records).Count + DurationMs = [math]::Round(([datetimeoffset]::UtcNow - $categoryStarted).TotalMilliseconds) + }) + if (@($scan.Errors).Count -eq 0) { [void]$completedIds.Add($categoryDefinition.Id) } + & $saveCheckpoint + } + + $completedAt = [datetimeoffset]::UtcNow + $remaining = @($selectedIds | Where-Object { -not $completedIds.Contains($_) }) + $isComplete = -not $budgetExceeded -and $remaining.Count -eq 0 -and $errors.Count -eq 0 + $result = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentScanRun' + SchemaVersion = 1 + RunId = $runId + TenantId = $script:CurrentTenantId + TenantName = $script:CurrentTenantName + StartedAtUtc = $startedAt.ToUniversalTime().ToString('o') + CompletedAtUtc = $completedAt.ToUniversalTime().ToString('o') + DurationMs = [math]::Round(($completedAt - $startedAt).TotalMilliseconds) + Complete = $isComplete + BudgetExceeded = $budgetExceeded + Selected = @($selectedIds) + Completed = @($selectedIds | Where-Object { $completedIds.Contains($_) }) + Remaining = $remaining + Coverage = @($coverage) + Records = @($records) + Errors = @($errors) + Skipped = @($skipped) + Diagnostics = [PSCustomObject][ordered]@{ + ProviderCount = $selectedIds.Count + CompletedProviderCount = $completedIds.Count + RecordCount = $records.Count + ErrorCount = $errors.Count + SkippedCount = $skipped.Count + EntityCacheEntryCount = $entityCache.Count + CheckpointPath = $resolvedCheckpoint + GraphApiVersion = 'beta' + } + } + $result.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentScanRun') + + if ($resolvedCheckpoint -and ($KeepCheckpoint -or -not $isComplete)) { & $saveCheckpoint } + elseif ($resolvedCheckpoint -and (Test-Path -LiteralPath $resolvedCheckpoint)) { + Remove-Item -LiteralPath $resolvedCheckpoint -Force + } + $result +} diff --git a/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 b/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 new file mode 100644 index 0000000..35d9f59 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 @@ -0,0 +1,104 @@ +function Start-IntuneAssignmentCheckerTui { + <# + .SYNOPSIS + Starts the keyboard-driven IntuneAssignmentChecker terminal interface. + + .DESCRIPTION + Presents the same operational surface as the PowerShell module. Commands, + parameter sets, mandatory inputs, switches, credentials, secure strings, and + ValidateSet choices are discovered dynamically from the exported cmdlets. + No separate application logic or converted executable is used. + + .PARAMETER InitialFilter + Filters the initial operation list by name, category, synopsis, or capability. + + .PARAMETER Command + Opens the parameter editor for one command directly. + #> + [CmdletBinding()] + param( + [Parameter()] + [string]$InitialFilter, + + [Parameter()] + [string]$Command + ) + + if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { + throw 'The terminal UI requires an interactive terminal. Use the individual module cmdlets for automation.' + } + + $catalog = @(Get-IACOperationCatalog) + if ($Command) { + $operation = $catalog | Where-Object Name -EQ $Command | Select-Object -First 1 + if (-not $operation) { throw "Unknown IntuneAssignmentChecker operation '$Command'." } + Show-IACTuiOperation -Operation $operation + return + } + + $filter = "$InitialFilter" + $selectedIndex = 0 + while ($true) { + $operations = if ([string]::IsNullOrWhiteSpace($filter)) { @($catalog) } + else { + @($catalog | Where-Object { + $_.Name -like "*$filter*" -or $_.Category -like "*$filter*" -or + $_.Synopsis -like "*$filter*" -or (@($_.Capabilities) -join ' ') -like "*$filter*" + }) + } + if ($operations.Count -eq 0) { + $filter = '' + $selectedIndex = 0 + continue + } + if ($selectedIndex -ge $operations.Count) { $selectedIndex = $operations.Count - 1 } + + Show-IACTuiScreen -Operations $operations -SelectedIndex $selectedIndex -Filter $filter + $key = [Console]::ReadKey($true) + switch ($key.Key) { + 'UpArrow' { if ($selectedIndex -gt 0) { $selectedIndex-- } } + 'DownArrow' { if ($selectedIndex -lt $operations.Count - 1) { $selectedIndex++ } } + 'PageUp' { $selectedIndex = [math]::Max(0, $selectedIndex - 10) } + 'PageDown' { $selectedIndex = [math]::Min($operations.Count - 1, $selectedIndex + 10) } + 'Home' { $selectedIndex = 0 } + 'End' { $selectedIndex = $operations.Count - 1 } + 'Enter' { Show-IACTuiOperation -Operation $operations[$selectedIndex] } + 'C' { + Show-IACTuiOperation -Operation ($catalog | Where-Object Name -EQ 'Switch-IntuneAssignmentCheckerTenant' | Select-Object -First 1) + } + 'T' { + Show-IACTuiOperation -Operation ($catalog | Where-Object Name -EQ 'Switch-IntuneAssignmentCheckerTenant' | Select-Object -First 1) + } + 'Q' { Clear-Host; return } + 'Escape' { Clear-Host; return } + 'Oem2' { + Write-Host '' + $filter = Read-Host 'Filter operations (blank clears)' + $selectedIndex = 0 + } + default { + if ($key.KeyChar -eq '/') { + Write-Host '' + $filter = Read-Host 'Filter operations (blank clears)' + $selectedIndex = 0 + } + elseif ($key.KeyChar -in @('j', 'J') -and $selectedIndex -lt $operations.Count - 1) { $selectedIndex++ } + elseif ($key.KeyChar -in @('k', 'K') -and $selectedIndex -gt 0) { $selectedIndex-- } + elseif ($key.KeyChar -eq '?') { + Clear-Host + Write-IACTuiText -Text 'Terminal UI help' -Style Accent + Write-Host @' + +The TUI discovers its commands from the imported module. Select an operation, +choose a parameter set, and enter values. Optional values can be skipped with +Enter. Arrays accept comma-separated values or @path-to-json. Secure inputs are +never echoed. Press C to disconnect and connect to another tenant. Commands still return the same structured PowerShell objects and +use the same Microsoft Graph beta transport as direct cmdlet invocation. +'@ + Write-IACTuiText -Text 'Press any key to return.' -Style Muted -NoNewline + $null = [Console]::ReadKey($true) + } + } + } + } +} diff --git a/Module/IntuneAssignmentChecker/Public/Switch-IntuneAssignmentCheckerTenant.ps1 b/Module/IntuneAssignmentChecker/Public/Switch-IntuneAssignmentCheckerTenant.ps1 new file mode 100644 index 0000000..220e05e --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Switch-IntuneAssignmentCheckerTenant.ps1 @@ -0,0 +1,38 @@ +function Switch-IntuneAssignmentCheckerTenant { + <# + .SYNOPSIS + Disconnects the current Microsoft Graph session and connects to another tenant. + + .DESCRIPTION + Clears every tenant-scoped module cache before reconnecting. With no credential + parameters, the normal interactive connection flow is used. + #> + [CmdletBinding()] + param( + [Parameter()][string]$AppId, + [Parameter()][string]$TenantId, + [Parameter()][string]$CertificateThumbprint, + [Parameter()][PSCredential]$ClientSecretCredential, + [Parameter()][SecureString]$AccessToken, + [Parameter()][ValidateSet('Global', 'USGov', 'USGovDoD')][string]$Environment = 'Global', + [Parameter()][ValidateSet('Core', 'Applications', 'Devices', 'Scripts', 'CloudPC', 'ScopeTags', 'Audit', 'Full')][string[]]$Capability = @('Full'), + [Parameter()][switch]$SkipPermissionPrompt, + [Parameter()][switch]$PassThru + ) + + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + foreach ($variableName in @( + 'GraphEndpoint', 'GraphEnvironment', 'CurrentTenantId', 'CurrentTenantName', + 'CurrentUserUPN', 'TemplateIdToFamilyCache', 'ScopeTagLookup', + 'AssignmentFilterLookup', 'GroupInfoCache' + )) { + Set-Variable -Name $variableName -Scope Script -Value $null + } + + $connect = @{ Environment = $Environment; Capability = $Capability; SkipPermissionPrompt = $SkipPermissionPrompt; PassThru = $true } + foreach ($name in @('AppId', 'TenantId', 'CertificateThumbprint', 'ClientSecretCredential', 'AccessToken')) { + if ($PSBoundParameters.ContainsKey($name)) { $connect[$name] = $PSBoundParameters[$name] } + } + $connection = Connect-IntuneAssignmentChecker @connect + if ($PassThru) { $connection } +} diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentChange.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentChange.ps1 new file mode 100644 index 0000000..f6d1bc0 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentChange.ps1 @@ -0,0 +1,185 @@ +function Test-IntuneAssignmentChange { + <# + .SYNOPSIS + Simulates a proposed assignment change without writing to Microsoft Graph. + + .DESCRIPTION + Applies an add, remove, target replacement, filter change, or intent change to + records from a snapshot or the pipeline. The result contains before and after + states, risk, affected known subjects, and an explicit simulation reason chain. + + .PARAMETER ChangeType + Assignment mutation to model locally. + + .PARAMETER PolicyId + Policy or application whose assignment should be simulated. + + .PARAMETER AssignmentId + Existing assignment for remove/change operations. + #> + [CmdletBinding(DefaultParameterSetName = 'Snapshot')] + [OutputType('IntuneAssignmentChecker.AssignmentChangeSimulation')] + param( + [Parameter(Mandatory, ParameterSetName = 'Snapshot')] + [string]$SnapshotPath, + + [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Records')] + [AllowEmptyCollection()] + [object[]]$InputObject, + + [Parameter(Mandatory)] + [ValidateSet('AddAssignment', 'RemoveAssignment', 'ReplaceTarget', 'ChangeFilter', 'ChangeIntent')] + [string]$ChangeType, + + [Parameter(Mandatory)] + [string]$PolicyId, + + [Parameter()] + [string]$AssignmentId, + + [Parameter()] + [ValidateSet('Include', 'Exclude')] + [string]$AssignmentMode = 'Include', + + [Parameter()] + [ValidateSet('AllUsers', 'AllDevices', 'Group')] + [string]$TargetType = 'Group', + + [Parameter()] + [string]$TargetId, + + [Parameter()] + [string]$TargetName, + + [Parameter()] + [string]$FilterId, + + [Parameter()] + [ValidateSet('include', 'exclude', 'none')] + [string]$FilterMode = 'none', + + [Parameter()] + [string]$Intent, + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [ValidateSet('Json', 'JsonLines', 'Csv')] + [string]$OutputFormat = 'Json' + ) + + begin { $pipelineRecords = [System.Collections.Generic.List[object]]::new() } + process { + if ($PSCmdlet.ParameterSetName -eq 'Records') { + foreach ($record in @($InputObject)) { if ($null -ne $record) { [void]$pipelineRecords.Add($record) } } + } + } + end { + $records = if ($PSCmdlet.ParameterSetName -eq 'Snapshot') { + @((Read-IACAssignmentSnapshot -Path $SnapshotPath).Records) + } + else { @($pipelineRecords) } + $policyRecords = @($records | Where-Object PolicyId -EQ $PolicyId) + if ($policyRecords.Count -eq 0) { throw "Policy '$PolicyId' was not found in the supplied assignment state." } + + $requiresExisting = $ChangeType -ne 'AddAssignment' + $existing = if ($AssignmentId) { + $policyRecords | Where-Object AssignmentId -EQ $AssignmentId | Select-Object -First 1 + } + elseif ($requiresExisting -and $policyRecords.Count -eq 1) { $policyRecords[0] } + if ($requiresExisting -and -not $existing) { + throw 'Specify -AssignmentId when the proposed change operates on an existing assignment.' + } + if ($ChangeType -in @('AddAssignment', 'ReplaceTarget') -and $TargetType -eq 'Group' -and [string]::IsNullOrWhiteSpace($TargetId)) { + throw "-$ChangeType with a Group target requires -TargetId." + } + + $before = if ($existing) { $existing.PSObject.Copy() } else { $null } + $after = switch ($ChangeType) { + 'RemoveAssignment' { $null } + 'AddAssignment' { + $copy = $policyRecords[0].PSObject.Copy() + $identity = "$PolicyId|$AssignmentMode|$TargetType|$TargetId|$FilterId|$Intent" + $hash = (Get-IACSha256Hex -InputText $identity).Substring(0, 20) + $copy.AssignmentId = "simulation:$hash" + $copy.AssignmentMode = $AssignmentMode + $copy.TargetType = $TargetType + $copy.TargetId = if ($TargetType -eq 'Group') { $TargetId } else { $null } + $copy.TargetName = if ($TargetName) { $TargetName } else { $TargetType } + if ($PSBoundParameters.ContainsKey('Intent')) { $copy.Intent = $Intent } + if ($PSBoundParameters.ContainsKey('FilterId')) { $copy.FilterId = $FilterId; $copy.FilterMode = $FilterMode } + $copy + } + 'ReplaceTarget' { + $copy = $existing.PSObject.Copy() + $copy.AssignmentMode = $AssignmentMode + $copy.TargetType = $TargetType + $copy.TargetId = if ($TargetType -eq 'Group') { $TargetId } else { $null } + $copy.TargetName = if ($TargetName) { $TargetName } else { $TargetType } + $copy + } + 'ChangeFilter' { + $copy = $existing.PSObject.Copy() + $copy.FilterId = $FilterId + $copy.FilterMode = $FilterMode + $copy + } + 'ChangeIntent' { + if (-not $PSBoundParameters.ContainsKey('Intent')) { throw 'ChangeIntent requires -Intent.' } + $copy = $existing.PSObject.Copy() + $copy.Intent = $Intent + $copy + } + } + + if ($after) { + $reason = [PSCustomObject][ordered]@{ + Sequence = @($after.ReasonChain).Count + Code = "Simulation.$ChangeType" + Outcome = 'Proposed' + AssignmentId = $after.AssignmentId + AssignmentMode = $after.AssignmentMode + TargetType = $after.TargetType + TargetId = $after.TargetId + FilterId = $after.FilterId + FilterMode = $after.FilterMode + Message = 'Local simulation only; no Microsoft Graph write was performed.' + } + $after.ReasonChain = @($after.ReasonChain) + @($reason) + if ($after.PSObject.Properties['Source']) { $after.Source = 'Simulation' } + else { $after | Add-Member -NotePropertyName Source -NotePropertyValue 'Simulation' } + } + + $affectedSubjects = @($records | Where-Object { + $_.PolicyId -eq $PolicyId -and -not [string]::IsNullOrWhiteSpace("$($_.SubjectId)") + } | Select-Object SubjectType, SubjectId, SubjectName, EffectiveState -Unique) + $risk = if (($after -and $after.TargetType -in @('AllUsers', 'AllDevices')) -and ($after.Intent -eq 'required')) { 'Critical' } + elseif ($after -and $after.TargetType -in @('AllUsers', 'AllDevices')) { 'High' } + elseif ($ChangeType -eq 'RemoveAssignment') { 'High' } + else { 'Medium' } + $simulation = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentChangeSimulation' + SchemaVersion = 1 + SimulationId = [guid]::NewGuid().ToString() + SimulatedAtUtc = [datetimeoffset]::UtcNow.ToString('o') + ReadOnly = $true + ChangeType = $ChangeType + Risk = $risk + PolicyId = $PolicyId + PolicyName = $policyRecords[0].PolicyName + AssignmentId = if ($after) { $after.AssignmentId } else { $before.AssignmentId } + Before = $before + After = $after + AffectedSubjects = $affectedSubjects + AffectedCount = $affectedSubjects.Count + CoverageNote = if ($affectedSubjects.Count -eq 0) { + 'No subject-scoped effective records were supplied; affected users and devices cannot be enumerated.' + } + else { 'Affected subjects are limited to the supplied effective-assignment records.' } + } + $simulation.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentChangeSimulation') + if ($OutputPath) { $null = Export-IACStructuredOutput -InputObject @($simulation) -Path $OutputPath -Format $OutputFormat } + $simulation + } +} diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentCheckerEnvironment.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentCheckerEnvironment.ps1 new file mode 100644 index 0000000..b1adcb3 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentCheckerEnvironment.ps1 @@ -0,0 +1,108 @@ +function Test-IntuneAssignmentCheckerEnvironment { + <# + .SYNOPSIS + Validates the local runtime, Graph connection, capabilities, and beta endpoints. + + .DESCRIPTION + Returns one structured result per diagnostic check. When Microsoft Graph is + connected, read-only beta probes verify organization, assignment-filter, RBAC, + and audit availability according to the requested capability profiles. + + .PARAMETER OutputPath + Optional directory whose existence and write access should be checked. + + .PARAMETER SkipGraphProbe + Reports connection and permission state without sending endpoint probes. + + .PARAMETER FailOnError + Throws after returning diagnostics when any check failed. + #> + [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.EnvironmentDiagnostic')] + param( + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [switch]$SkipGraphProbe, + + [Parameter()] + [switch]$FailOnError + ) + + $results = [System.Collections.Generic.List[object]]::new() + $addResult = { + param($Check, $Status, $Detail, $Remediation, $Capability) + $item = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.EnvironmentDiagnostic' + SchemaVersion = 1 + Check = $Check + Status = $Status + Capability = $Capability + Detail = $Detail + Remediation = $Remediation + CheckedAtUtc = [datetimeoffset]::UtcNow.ToString('o') + } + $item.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.EnvironmentDiagnostic') + [void]$results.Add($item) + } + + & $addResult 'PowerShellVersion' $(if ($PSVersionTable.PSVersion.Major -ge 7) { 'Passed' } else { 'Failed' }) "$($PSVersionTable.PSVersion)" 'Install PowerShell 7 or newer.' 'Core' + $graphModule = Get-Module -ListAvailable -Name Microsoft.Graph.Authentication | Sort-Object Version -Descending | Select-Object -First 1 + & $addResult 'GraphAuthenticationModule' $(if ($graphModule) { 'Passed' } else { 'Failed' }) $(if ($graphModule) { "$($graphModule.Version)" } else { 'Not installed' }) 'Install-Module Microsoft.Graph.Authentication -Scope CurrentUser' 'Core' + + $context = Get-MgContext -ErrorAction SilentlyContinue + & $addResult 'GraphConnection' $(if ($context -and $script:GraphEndpoint) { 'Passed' } else { 'Failed' }) $(if ($context) { "Tenant $($context.TenantId) in $($context.Environment)" } else { 'Not connected' }) 'Run Connect-IntuneAssignmentChecker.' 'Core' + & $addResult 'BetaTransport' $(if ($script:GraphEndpoint) { 'Passed' } else { 'Skipped' }) $(if ($script:GraphEndpoint) { "$($script:GraphEndpoint.TrimEnd('/'))/beta" } else { 'No active Graph endpoint' }) 'Connect before running Graph diagnostics.' 'Core' + + foreach ($capability in @($script:CapabilityStatus)) { + $capabilityResult = switch ($capability.Status) { 'Available' { 'Passed' } 'Unavailable' { 'Failed' } default { $capability.Status } } + $capabilityDetail = if ($capability.MissingPermissions.Count -gt 0) { "Missing: $($capability.MissingPermissions -join ', ')" } else { $capability.Status } + & $addResult "Capability.$($capability.Name)" $capabilityResult $capabilityDetail 'Reconnect with the required capability profile after granting its listed permissions.' $capability.Name + } + + if ($OutputPath) { + try { + $resolvedOutput = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) + if (-not (Test-Path -LiteralPath $resolvedOutput -PathType Container)) { + throw "Directory '$resolvedOutput' does not exist." + } + $probePath = Join-Path $resolvedOutput ".iac-write-probe-$([guid]::NewGuid().ToString('N'))" + [System.IO.File]::WriteAllText($probePath, 'probe', [System.Text.UTF8Encoding]::new($false)) + Remove-Item -LiteralPath $probePath -Force + & $addResult 'OutputPath' 'Passed' $resolvedOutput 'Choose a writable output directory.' 'Core' + } + catch { + & $addResult 'OutputPath' 'Failed' $_.Exception.Message 'Choose a writable output directory.' 'Core' + } + } + + if (-not $SkipGraphProbe -and $context -and $script:GraphEndpoint) { + $probes = @( + [PSCustomObject]@{ Check = 'Graph.Organization'; Capability = 'Core'; Uri = '/organization?$select=id,displayName&$top=1' } + [PSCustomObject]@{ Check = 'Graph.AssignmentFilters'; Capability = 'Devices'; Uri = '/deviceManagement/assignmentFilters?$select=id,displayName,platform,rule&$top=1' } + [PSCustomObject]@{ Check = 'Graph.RoleAssignments'; Capability = 'ScopeTags'; Uri = '/deviceManagement/roleAssignments?$select=id,displayName,members,resourceScopes,roleScopeTagIds&$top=1' } + [PSCustomObject]@{ Check = 'Graph.AuditEvents'; Capability = 'Audit'; Uri = '/deviceManagement/auditEvents?$select=id,displayName,activityDateTime,actor,resources&$top=1' } + ) + foreach ($probe in $probes) { + $capabilityState = $script:CapabilityStatus | Where-Object Name -EQ $probe.Capability | Select-Object -First 1 + if ($capabilityState -and $capabilityState.Status -eq 'Skipped') { + & $addResult $probe.Check 'Skipped' "Capability $($probe.Capability) was not requested." "Reconnect with -Capability $($probe.Capability)." $probe.Capability + continue + } + try { + $response = Invoke-IACGraphRequest -Uri $probe.Uri -Method GET -FirstPageOnly -ErrorAction Stop + $count = if ($null -ne $response.value) { @($response.value).Count } else { 1 } + & $addResult $probe.Check 'Passed' "Beta endpoint responded; sample count $count." 'No action required.' $probe.Capability + } + catch { + & $addResult $probe.Check 'Failed' $_.Exception.Message "Verify the $($probe.Capability) permission profile and tenant workload availability." $probe.Capability + } + } + } + + $results | Write-Output + if ($FailOnError -and @($results | Where-Object Status -EQ 'Failed').Count -gt 0) { + throw 'One or more IntuneAssignmentChecker environment diagnostics failed.' + } +} diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentFilterSet.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentFilterSet.ps1 new file mode 100644 index 0000000..5770fae --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentFilterSet.ps1 @@ -0,0 +1,155 @@ +function Test-IntuneAssignmentFilterSet { + <# + .SYNOPSIS + Audits all tenant assignment filters and their policy references. + + .DESCRIPTION + Finds unused, orphaned, duplicate, unsupported, platform-mismatched, match-all, + and match-none filters. Optional device samples are evaluated through the same + safe local parser used by Test-IntuneAssignmentFilter; filter text is never run. + + .PARAMETER SnapshotPath + Assignment snapshot used to resolve filter references offline. + + .PARAMETER FilterDefinitionPath + Optional JSON export of assignment-filter definitions for offline analysis. + + .PARAMETER DeviceName + Managed-device names or IDs used to identify match-all or match-none filters. + #> + [CmdletBinding()] + [OutputType('IntuneAssignmentChecker.AssignmentFilterSetFinding')] + param( + [Parameter()] + [string]$SnapshotPath, + + [Parameter()] + [string]$FilterDefinitionPath, + + [Parameter()] + [string[]]$DeviceName = @(), + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [ValidateSet('Json', 'JsonLines', 'Csv')] + [string]$OutputFormat = 'Json' + ) + + $coverageComplete = $true + $coverageErrors = @() + $records = if ($SnapshotPath) { + $snapshot = Read-IACAssignmentSnapshot -Path $SnapshotPath + $coverageComplete = -not (Test-IACCoverageHasBlockingFailure -Coverage $snapshot.Coverage) + $coverageErrors = @($snapshot.Coverage.Errors) + @($snapshot.Records) + } + else { + if (-not $script:GraphEndpoint) { throw 'Connect first with Connect-IntuneAssignmentChecker or supply -SnapshotPath.' } + $categories = @(Get-IntuneCategoryDefinition -Audience Effective) + $scan = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity {} -EntityCache @{} -BuildRecords + $coverageErrors = @($scan.Errors) + $coverageComplete = $coverageErrors.Count -eq 0 + @($scan.Records) + } + $filters = if ($FilterDefinitionPath) { + $document = Get-Content -LiteralPath $FilterDefinitionPath -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 20 -ErrorAction Stop + if ($document.Filters) { @($document.Filters) } else { @($document) } + } + else { + if (-not $script:GraphEndpoint) { + @($records | Where-Object FilterId | Group-Object FilterId | ForEach-Object { + $sample = $_.Group[0] + [PSCustomObject]@{ Id = $sample.FilterId; Name = $sample.FilterName; Platform = $sample.FilterPlatform; Rule = $sample.FilterRule; AssignmentFilterManagementType = $null } + }) + } + else { + $script:AssignmentFilterLookup = Get-AssignmentFilterLookup + @($script:AssignmentFilterLookup.Values) + } + } + + $findings = [System.Collections.Generic.List[object]]::new() + $addFinding = { + param($RuleId, $Severity, $Title, $Filter, $Message, $Evidence, $Remediation) + $item = [PSCustomObject][ordered]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentFilterSetFinding' + SchemaVersion = 1 + RuleId = $RuleId + Severity = $Severity + Title = $Title + FilterId = $Filter.Id + FilterName = $Filter.Name + Platform = $Filter.Platform + Rule = $Filter.Rule + Message = $Message + References = @($records | Where-Object FilterId -EQ $Filter.Id | Select-Object CategoryId, PolicyId, PolicyName, AssignmentId -Unique) + Evidence = $Evidence + Remediation = $Remediation + DetectedAtUtc = [datetimeoffset]::UtcNow.ToString('o') + } + $item.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentFilterSetFinding') + [void]$findings.Add($item) + } + + $filterIds = @($filters.Id | ForEach-Object { "$_" }) + if (-not $coverageComplete) { + $coverageFilter = [PSCustomObject]@{ Id = $null; Name = 'Scan coverage'; Platform = $null; Rule = $null } + & $addFinding 'IAF007' 'High' 'Incomplete assignment coverage' $coverageFilter 'Unused-filter findings were suppressed because one or more assignment workloads were unavailable.' @{ Errors = @($coverageErrors) } 'Restore workload access and rerun the filter-set audit before removing filters.' + } + foreach ($reference in @($records | Where-Object FilterId | Group-Object FilterId)) { + if ($reference.Name -notin $filterIds) { + $sample = $reference.Group[0] + $missing = [PSCustomObject]@{ Id = $sample.FilterId; Name = $sample.FilterName; Platform = $sample.FilterPlatform; Rule = $sample.FilterRule } + & $addFinding 'IAF006' 'High' 'Orphaned filter reference' $missing "Assignments reference missing filter '$($sample.FilterId)'." @{ ReferenceCount = $reference.Count } 'Replace or remove the stale filter reference.' + } + } + + foreach ($filter in $filters) { + $references = @($records | Where-Object FilterId -EQ $filter.Id) + if ($coverageComplete -and $references.Count -eq 0) { + & $addFinding 'IAF001' 'Low' 'Unused assignment filter' $filter "Filter '$($filter.Name)' is not referenced by the scanned assignments." @{ ReferenceCount = 0 } 'Confirm the scan is complete, then remove or document the unused filter.' + } + try { + $tokens = ConvertTo-IACTokenList -Rule "$($filter.Rule)" + $null = ConvertTo-IACFilterAst -Tokens $tokens + } + catch { + & $addFinding 'IAF003' 'High' 'Unsupported assignment filter expression' $filter "Filter '$($filter.Name)' cannot be evaluated safely: $($_.Exception.Message)" @{ ParserError = $_.Exception.Message } 'Rewrite the rule with documented properties and supported operators.' + } + $managementType = "$($filter.AssignmentFilterManagementType)" + if (($managementType -eq 'devices' -and "$($filter.Rule)" -match '(?i)\bapp\.') -or + ($managementType -eq 'apps' -and "$($filter.Rule)" -match '(?i)\bdevice\.')) { + & $addFinding 'IAF004' 'High' 'Filter management type mismatch' $filter "Filter '$($filter.Name)' uses properties that conflict with management type '$managementType'." @{ AssignmentFilterManagementType = $managementType } 'Align the rule property prefix and assignmentFilterManagementType.' + } + } + + foreach ($duplicates in @($filters | Group-Object { + ("$($_.Rule)" -replace '\s+', '').ToLowerInvariant() + '|' + "$($_.Platform)".ToLowerInvariant() + '|' + "$($_.AssignmentFilterManagementType)".ToLowerInvariant() + } | Where-Object Count -gt 1)) { + foreach ($filter in $duplicates.Group) { + & $addFinding 'IAF002' 'Medium' 'Duplicate assignment filter' $filter "Filter '$($filter.Name)' duplicates $($duplicates.Count - 1) other filter(s)." @{ DuplicateFilterIds = @($duplicates.Group.Id | Where-Object { $_ -ne $filter.Id }) } 'Consolidate references onto one filter and retire the duplicates.' + } + } + + if ($DeviceName.Count -gt 0) { + if (-not $script:GraphEndpoint) { throw '-DeviceName evaluation requires an active Microsoft Graph connection.' } + foreach ($filter in $filters) { + $evaluations = foreach ($device in $DeviceName) { + Test-IntuneAssignmentFilter -DeviceName $device -FilterId "$($filter.Id)" -FilterMode Include + } + $known = @($evaluations | Where-Object Result -In @('Match', 'NotMatch')) + if ($known.Count -eq $DeviceName.Count -and @($known | Where-Object Result -EQ 'Match').Count -eq $known.Count) { + & $addFinding 'IAF005' 'Medium' 'Filter matches every sampled device' $filter "Filter '$($filter.Name)' matched all $($known.Count) sampled devices." @{ Evaluations = @($evaluations) } 'Review whether the filter meaningfully narrows the assignment.' + } + elseif ($known.Count -eq $DeviceName.Count -and @($known | Where-Object Result -EQ 'NotMatch').Count -eq $known.Count) { + & $addFinding 'IAF005' 'Medium' 'Filter matches no sampled devices' $filter "Filter '$($filter.Name)' matched none of the $($known.Count) sampled devices." @{ Evaluations = @($evaluations) } 'Validate the filter rule and sample population before relying on the assignment.' + } + } + } + + $ordered = @($findings | Sort-Object @{ Expression = { -(Get-IACSeverityRank $_.Severity) } }, RuleId, FilterName) + if ($OutputPath) { $null = Export-IACStructuredOutput -InputObject $ordered -Path $OutputPath -Format $OutputFormat } + $ordered | Write-Output +} diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentGovernance.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentGovernance.ps1 new file mode 100644 index 0000000..4bc868e --- /dev/null +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentGovernance.ps1 @@ -0,0 +1,198 @@ +function Test-IntuneAssignmentGovernance { + <# + .SYNOPSIS + Evaluates Intune assignment records against configurable governance rules. + + .DESCRIPTION + Scans the connected tenant, a saved snapshot, or pipeline assignment records. + Findings include severity, stable rule and finding IDs, evidence, remediation, + and optional waiver state. The cmdlet never changes Intune assignments. + + .PARAMETER SnapshotPath + Saved assignment snapshot to evaluate offline. + + .PARAMETER InputObject + Assignment records supplied through the pipeline. + + .PARAMETER RulePath + Optional JSON rule configuration replacing the built-in rule pack. + + .PARAMETER WaiverPath + Optional JSON waiver document with RuleId, optional PolicyId/TargetId, + owner, justification, and ExpiresAtUtc fields. + + .PARAMETER SkipGroupResolution + Does not query target group membership. Offline snapshots automatically skip it. + + .PARAMETER FailOnSeverity + Throws when an unsuppressed finding at or above this severity exists. + #> + [CmdletBinding(DefaultParameterSetName = 'Tenant')] + [OutputType('IntuneAssignmentChecker.GovernanceFinding')] + param( + [Parameter(Mandatory, ParameterSetName = 'Snapshot')] + [string]$SnapshotPath, + + [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Records')] + [AllowEmptyCollection()] + [object[]]$InputObject, + + [Parameter()] + [string]$RulePath, + + [Parameter()] + [string]$WaiverPath, + + [Parameter()] + [switch]$IncludeSuppressed, + + [Parameter()] + [switch]$SkipGroupResolution, + + [Parameter()] + [string[]]$CriticalCategory = @('CompliancePolicies', 'Applications', 'ESAntivirus', 'ESEndpointDetection'), + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [ValidateSet('Json', 'JsonLines', 'Csv')] + [string]$OutputFormat = 'Json', + + [Parameter()] + [ValidateSet('None', 'Low', 'Medium', 'High', 'Critical')] + [string]$FailOnSeverity = 'None', + + [Parameter()] + [switch]$SetExitCode + ) + + begin { $pipelineRecords = [System.Collections.Generic.List[object]]::new() } + process { + if ($PSCmdlet.ParameterSetName -eq 'Records') { + foreach ($record in @($InputObject)) { if ($null -ne $record) { [void]$pipelineRecords.Add($record) } } + } + } + end { + $coverage = $null + $resolveGroups = $false + switch ($PSCmdlet.ParameterSetName) { + 'Snapshot' { + $snapshot = Read-IACAssignmentSnapshot -Path $SnapshotPath + $records = @($snapshot.Records) + $coverage = $snapshot.Coverage + } + 'Records' { $records = @($pipelineRecords) } + default { + if (-not $script:GraphEndpoint) { throw 'Connect first with Connect-IntuneAssignmentChecker or supply -SnapshotPath/-InputObject.' } + if ($null -eq $script:AssignmentFilterLookup) { $script:AssignmentFilterLookup = Get-AssignmentFilterLookup } + $categories = @(Get-IntuneCategoryDefinition -Audience Effective) + $scan = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity {} -EntityCache @{} -BuildRecords + $records = @($scan.Records) + $coverage = [PSCustomObject]@{ + Complete = @($scan.Errors).Count -eq 0 + Categories = @($categories | ForEach-Object { + $category = $_ + [PSCustomObject]@{ + CategoryId = $category.Id + Status = if (@($scan.Errors | Where-Object CategoryId -EQ $category.Id).Count -gt 0) { 'Failed' } else { 'Captured' } + } + }) + Errors = @($scan.Errors) + } + $resolveGroups = -not $SkipGroupResolution + } + } + + $rules = @(Get-IACGovernanceRule -RulePath $RulePath) + $ruleById = @{} + foreach ($rule in $rules) { if ($rule.enabled) { $ruleById["$($rule.id)"] = $rule } } + $waivers = @() + if ($WaiverPath) { + $waiverDocument = Get-Content -LiteralPath $WaiverPath -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 20 -ErrorAction Stop + $waivers = if ($waiverDocument.Waivers) { @($waiverDocument.Waivers) } else { @($waiverDocument) } + foreach ($waiverEntry in $waivers) { + foreach ($requiredWaiverField in @('RuleId', 'Owner', 'Justification', 'ExpiresAtUtc')) { + if ([string]::IsNullOrWhiteSpace("$($waiverEntry.$requiredWaiverField)")) { + throw "Governance waivers require $requiredWaiverField." + } + } + } + } + $findings = [System.Collections.Generic.List[object]]::new() + $addFinding = { + param($RuleId, $Record, $Message, $Remediation, $Evidence) + if (-not $ruleById.ContainsKey($RuleId)) { return } + $waiver = Find-IACGovernanceWaiver -Waiver $waivers -RuleId $RuleId -PolicyId "$($Record.PolicyId)" -TargetId "$($Record.TargetId)" + $finding = New-IACGovernanceFinding -Rule $ruleById[$RuleId] -Record $Record -Message $Message -Remediation $Remediation -Evidence $Evidence -Waiver $waiver + if (-not $finding.Suppressed -or $IncludeSuppressed) { [void]$findings.Add($finding) } + } + + foreach ($record in $records) { + if ($record.TargetType -eq 'AllUsers' -and $record.AssignmentMode -eq 'Include') { + & $addFinding 'IAC001' $record "'$($record.PolicyName)' targets All Users." 'Replace broad targeting with approved groups or document a time-bound waiver.' @{ TargetType = 'AllUsers'; Intent = $record.Intent } + } + if ($record.TargetType -eq 'AllDevices' -and $record.AssignmentMode -eq 'Include') { + & $addFinding 'IAC002' $record "'$($record.PolicyName)' targets All Devices." 'Replace broad targeting with approved device groups or document a time-bound waiver.' @{ TargetType = 'AllDevices'; Intent = $record.Intent } + } + if ($record.TargetType -eq 'Group' -and [string]::IsNullOrWhiteSpace("$($record.TargetName)")) { + & $addFinding 'IAC004' $record "Target group '$($record.TargetId)' could not be resolved." 'Restore the target group or remove the stale assignment.' @{ Resolution = 'Unresolved' } + } + if ($record.FilterId -and $record.EffectiveState -eq 'Unknown') { + & $addFinding 'IAC006' $record "Assignment filter '$($record.FilterId)' produced an unknown effective result." 'Validate the filter rule, supported properties, platform, and device inventory values.' @{ FilterId = $record.FilterId; FilterRule = $record.FilterRule; FilterPlatform = $record.FilterPlatform } + } + if ($record.AssignmentMode -eq 'None' -and $record.CategoryId -in $CriticalCategory) { + & $addFinding 'IAC008' $record "Critical item '$($record.PolicyName)' has no assignments." 'Assign the item to an approved target or remove it from the critical category list.' @{ CriticalCategory = $record.CategoryId } + } + } + + foreach ($policyGroup in @($records | Where-Object { $_.PolicyId } | Group-Object PolicyId)) { + $policyRecords = @($policyGroup.Group) + $sample = $policyRecords[0] + if ($sample.CategoryId -eq 'Applications' -and $policyRecords.Intent -contains 'required' -and + @($policyRecords | Where-Object AssignmentMode -EQ 'Exclude').Count -eq 0) { + & $addFinding 'IAC003' $sample "Required application '$($sample.PolicyName)' has no exclusion assignment." 'Add an appropriate exclusion group and document the rollback population.' @{ Intents = @($policyRecords.Intent | Sort-Object -Unique) } + } + foreach ($targetGroup in @($policyRecords | Where-Object TargetId | Group-Object TargetId)) { + $modes = @($targetGroup.Group.AssignmentMode | Sort-Object -Unique) + if ($modes -contains 'Include' -and $modes -contains 'Exclude') { + & $addFinding 'IAC005' $targetGroup.Group[0] "'$($sample.PolicyName)' both includes and excludes target '$($targetGroup.Name)'." 'Remove the contradictory assignment and validate effective targeting before deployment.' @{ Modes = $modes } + } + } + } + + if ($coverage -and (Test-IACCoverageHasBlockingFailure -Coverage $coverage)) { + $coverageRecord = if ($snapshot) { + [PSCustomObject]@{ TenantId = $snapshot.Tenant.Id; TenantName = $snapshot.Tenant.Name } + } + else { [PSCustomObject]@{ TenantId = $script:CurrentTenantId; TenantName = $script:CurrentTenantName } } + & $addFinding 'IAC007' $coverageRecord 'The evaluated snapshot has incomplete scan coverage.' 'Resolve failed or skipped workload scans and capture a complete snapshot.' @{ Categories = $coverage.Categories; Errors = $coverage.Errors } + } + + if ($resolveGroups -and $ruleById.ContainsKey('IAC004')) { + foreach ($groupRecord in @($records | Where-Object { $_.TargetType -eq 'Group' -and $_.TargetId } | Sort-Object TargetId -Unique)) { + try { + $groupId = [uri]::EscapeDataString("$($groupRecord.TargetId)") + $members = Invoke-IACGraphRequest -Uri "/groups/$groupId/members?`$select=id&`$top=1" -Method GET -FirstPageOnly -ErrorAction Stop + if (@($members.value).Count -eq 0) { + & $addFinding 'IAC004' $groupRecord "Target group '$($groupRecord.TargetName)' is empty." 'Populate the target group or remove the ineffective assignment.' @{ Resolution = 'Empty'; MemberCountSample = 0 } + } + } + catch { + & $addFinding 'IAC004' $groupRecord "Target group '$($groupRecord.TargetId)' could not be checked: $($_.Exception.Message)" 'Verify GroupMember.Read.All and confirm that the target group still exists.' @{ Resolution = 'CheckFailed' } + } + } + } + + $orderedFindings = @($findings | Sort-Object @{ Expression = { -(Get-IACSeverityRank $_.Severity) } }, RuleId, PolicyName, TargetName) + if ($OutputPath) { $null = Export-IACStructuredOutput -InputObject $orderedFindings -Path $OutputPath -Format $OutputFormat } + $orderedFindings | Write-Output + + $threshold = Get-IACSeverityRank -Severity $FailOnSeverity + $hasBlocking = $threshold -gt 0 -and @($orderedFindings | Where-Object { + -not $_.Suppressed -and (Get-IACSeverityRank -Severity $_.Severity) -ge $threshold + }).Count -gt 0 + if ($SetExitCode) { $global:LASTEXITCODE = if ($hasBlocking) { 2 } else { 0 } } + if ($hasBlocking) { throw "Governance findings met or exceeded the configured $FailOnSeverity threshold." } + } +} diff --git a/Module/IntuneAssignmentChecker/Schemas/README.md b/Module/IntuneAssignmentChecker/Schemas/README.md new file mode 100644 index 0000000..550f74d --- /dev/null +++ b/Module/IntuneAssignmentChecker/Schemas/README.md @@ -0,0 +1,8 @@ +# Public schemas + +Version 5 emits schema-governed objects. Assignment records and snapshots use +schema version 2. `ConvertTo-IntuneAssignmentRecord` migrates version 1 records, +and snapshot readers transparently migrate version 1 snapshots in memory. + +The module guarantees property names and documented enum values within a schema +major version. New optional fields may be introduced only in a new schema file. diff --git a/Module/IntuneAssignmentChecker/Schemas/assignment-record.v2.schema.json b/Module/IntuneAssignmentChecker/Schemas/assignment-record.v2.schema.json new file mode 100644 index 0000000..be8833b --- /dev/null +++ b/Module/IntuneAssignmentChecker/Schemas/assignment-record.v2.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/ugurkocde/IntuneAssignmentChecker/main/Module/IntuneAssignmentChecker/Schemas/assignment-record.v2.schema.json", + "title": "IntuneAssignmentChecker Assignment Record v2", + "type": "object", + "required": [ + "SchemaName", "SchemaVersion", "RecordId", "GraphApiVersion", "TenantId", + "CategoryId", "PolicyId", "AssignmentMode", "TargetType", "ScopeTagIds", + "ScopeTags", "ReasonChain", "Source" + ], + "properties": { + "SchemaName": { "const": "IntuneAssignmentChecker.AssignmentRecord" }, + "SchemaVersion": { "const": 2 }, + "RecordId": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "GraphApiVersion": { "const": "beta" }, + "TenantId": { "type": ["string", "null"] }, + "TenantName": { "type": ["string", "null"] }, + "SubjectType": { "type": ["string", "null"] }, + "SubjectId": { "type": ["string", "null"] }, + "SubjectName": { "type": ["string", "null"] }, + "CategoryId": { "type": "string" }, + "Category": { "type": ["string", "null"] }, + "PolicyId": { "type": "string" }, + "PolicyName": { "type": ["string", "null"] }, + "Platform": { "type": ["string", "null"] }, + "ScopeTagIds": { "type": "array", "items": { "type": "string" } }, + "ScopeTags": { "type": "array", "items": { "type": "string" } }, + "AssignmentId": { "type": ["string", "null"] }, + "AssignmentMode": { "enum": ["Include", "Exclude", "None", "Unknown"] }, + "TargetType": { "enum": ["AllUsers", "AllDevices", "Group", "None", "Unknown"] }, + "TargetId": { "type": ["string", "null"] }, + "TargetName": { "type": ["string", "null"] }, + "Intent": { "type": ["string", "null"] }, + "FilterId": { "type": ["string", "null"] }, + "FilterName": { "type": ["string", "null"] }, + "FilterMode": { "type": ["string", "null"] }, + "FilterRule": { "type": ["string", "null"] }, + "FilterPlatform": { "type": ["string", "null"] }, + "EffectiveState": { "enum": ["Included", "Excluded", "NotTargeted", "Unknown", null] }, + "ReasonChain": { "type": "array", "items": { "type": "object" } }, + "AssignmentReason": { "type": ["string", "null"] }, + "Source": { "type": ["string", "null"] } + }, + "additionalProperties": false +} diff --git a/Module/IntuneAssignmentChecker/Schemas/assignment-snapshot.v2.schema.json b/Module/IntuneAssignmentChecker/Schemas/assignment-snapshot.v2.schema.json new file mode 100644 index 0000000..1c99826 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Schemas/assignment-snapshot.v2.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/ugurkocde/IntuneAssignmentChecker/main/Module/IntuneAssignmentChecker/Schemas/assignment-snapshot.v2.schema.json", + "title": "IntuneAssignmentChecker Assignment Snapshot v2", + "type": "object", + "required": ["SchemaName", "SchemaVersion", "CapturedAtUtc", "ModuleVersion", "Tenant", "Coverage", "Records"], + "properties": { + "SchemaName": { "const": "IntuneAssignmentChecker.AssignmentSnapshot" }, + "SchemaVersion": { "const": 2 }, + "MigratedFromSchemaVersion": { "type": ["integer", "null"], "minimum": 1 }, + "CapturedAtUtc": { "type": "string", "format": "date-time" }, + "ModuleVersion": { "type": "string", "pattern": "^\\d+(?:\\.\\d+){1,3}(?:[-+].+)?$" }, + "Tenant": { + "type": "object", + "required": ["Id", "Name"], + "properties": { "Id": { "type": "string", "minLength": 1 }, "Name": { "type": ["string", "null"] } }, + "additionalProperties": false + }, + "Coverage": { + "type": "object", + "required": ["Mode", "Complete", "RecordCount", "Categories", "Errors"], + "properties": { + "Mode": { "enum": ["TenantScan", "ProvidedRecords"] }, + "Complete": { "type": "boolean" }, + "RecordCount": { "type": "integer", "minimum": 0 }, + "Categories": { "type": "array", "items": { "type": "object" } }, + "Errors": { "type": "array", "items": { "type": "object" } } + }, + "additionalProperties": false + }, + "Records": { + "type": "array", + "items": { "$ref": "assignment-record.v2.schema.json" } + } + }, + "additionalProperties": false +} diff --git a/Module/IntuneAssignmentChecker/Schemas/drift-event.v1.schema.json b/Module/IntuneAssignmentChecker/Schemas/drift-event.v1.schema.json new file mode 100644 index 0000000..2fd6f86 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Schemas/drift-event.v1.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/ugurkocde/IntuneAssignmentChecker/main/Module/IntuneAssignmentChecker/Schemas/drift-event.v1.schema.json", + "title": "IntuneAssignmentChecker Assignment Drift Event v1", + "type": "object", + "required": ["SchemaName", "SchemaVersion", "TenantId", "ChangeType", "Risk", "IdentityKey", "Attribution"], + "properties": { + "SchemaName": { "const": "IntuneAssignmentChecker.AssignmentDriftEvent" }, + "SchemaVersion": { "const": 1 }, + "TenantId": { "type": "string" }, + "TenantName": { "type": ["string", "null"] }, + "BaselineCaptured": { "type": "string", "format": "date-time" }, + "CurrentCaptured": { "type": "string", "format": "date-time" }, + "ChangeType": { "enum": ["Added", "Removed", "Changed"] }, + "Risk": { "enum": ["Low", "Medium", "High", "Critical"] }, + "IdentityKey": { "type": "string" }, + "CategoryId": { "type": ["string", "null"] }, + "PolicyId": { "type": ["string", "null"] }, + "PolicyName": { "type": ["string", "null"] }, + "AssignmentId": { "type": ["string", "null"] }, + "ChangedFields": { "type": "array", "items": { "type": "string" } }, + "Before": { "type": ["object", "null"] }, + "After": { "type": ["object", "null"] }, + "AuditEventId": { "type": ["string", "null"] }, + "AuditOperation": { "type": ["string", "null"] }, + "AuditActor": { "type": ["string", "null"] }, + "AuditActivityUtc": { "type": ["string", "null"] }, + "Attribution": { "enum": ["Correlated", "NotFound", "NotRequested"] } + }, + "additionalProperties": false +} diff --git a/Module/IntuneAssignmentChecker/Schemas/governance-finding.v1.schema.json b/Module/IntuneAssignmentChecker/Schemas/governance-finding.v1.schema.json new file mode 100644 index 0000000..076a759 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Schemas/governance-finding.v1.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/ugurkocde/IntuneAssignmentChecker/main/Module/IntuneAssignmentChecker/Schemas/governance-finding.v1.schema.json", + "title": "IntuneAssignmentChecker Governance Finding v1", + "type": "object", + "required": ["SchemaName", "SchemaVersion", "FindingId", "RuleId", "Severity", "Title", "Message", "Remediation", "Suppressed", "DetectedAtUtc"], + "properties": { + "SchemaName": { "const": "IntuneAssignmentChecker.GovernanceFinding" }, + "SchemaVersion": { "const": 1 }, + "FindingId": { "type": "string" }, + "RuleId": { "type": "string", "pattern": "^IAC\\d{3}$" }, + "Severity": { "enum": ["Low", "Medium", "High", "Critical"] }, + "Title": { "type": "string" }, + "Message": { "type": "string" }, + "TenantId": { "type": ["string", "null"] }, + "TenantName": { "type": ["string", "null"] }, + "CategoryId": { "type": ["string", "null"] }, + "PolicyId": { "type": ["string", "null"] }, + "PolicyName": { "type": ["string", "null"] }, + "AssignmentId": { "type": ["string", "null"] }, + "TargetId": { "type": ["string", "null"] }, + "TargetName": { "type": ["string", "null"] }, + "Evidence": {}, + "Remediation": { "type": "string" }, + "Suppressed": { "type": "boolean" }, + "Waiver": {}, + "DetectedAtUtc": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/README.md b/README.md index f2d39ea..4731976 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,33 @@ > **Important**: All commands must be run in a PowerShell 7 session. The module will not work in PowerShell 5.1 or earlier versions. -### Option 1: Install from PowerShell Gallery (Recommended) +### Option 1: Install with WinGet on Windows + +```powershell +winget install --id UgurKoc.IntuneAssignmentChecker --exact + +# Open PowerShell 7, then launch the full terminal UI +pwsh +Start-IntuneAssignmentCheckerTui +``` + +The WinGet package is an MSI that installs the PowerShell module and its Graph +authentication dependency. It does not install or generate an executable version +of IntuneAssignmentChecker. + +### Option 2: Install from PowerShell Gallery ```powershell # Install from PowerShell Gallery Install-Module IntuneAssignmentChecker -Scope CurrentUser -# Launch the interactive menu -IntuneAssignmentChecker +# Launch the full-parity terminal UI +Start-IntuneAssignmentCheckerTui ``` -The `IntuneAssignmentChecker` alias opens the menu-driven interface. Each feature is also available as a standalone cmdlet (see [Usage](#-usage)). +The legacy `IntuneAssignmentChecker` alias remains available. The v5 terminal UI +discovers the module's exported commands dynamically, so every module operation is +also available through `Start-IntuneAssignmentCheckerTui` without a separate UI codebase. If you encounter any issues during installation, try reinstalling: @@ -59,7 +75,7 @@ To update to the latest version: Update-Module IntuneAssignmentChecker ``` -### Option 2: Manual Installation (from a local clone) +### Option 3: Manual Installation (from a local clone) ```powershell # Install required Microsoft Graph SDK @@ -68,14 +84,25 @@ Install-Module Microsoft.Graph.Authentication -Scope CurrentUser # Import the module from your clone Import-Module ./Module/IntuneAssignmentChecker -Force -# Launch the interactive menu -IntuneAssignmentChecker +# Launch the terminal UI +Start-IntuneAssignmentCheckerTui ``` > **Migrating from v3.x?** v3.x shipped as a single script installed via `Install-Script`. v4.x is a PowerShell module installed via `Install-Module`. If you previously used `Install-Script IntuneAssignmentChecker`, uninstall it first: `Uninstall-Script IntuneAssignmentChecker`. ## ✨ Features +- 🖥️ Full-parity, dependency-free terminal UI generated from the module's real command metadata +- 🛡️ Policy-as-code assignment governance with severity, evidence, remediation, waivers, and automation exit behavior +- 💥 Read-only pre-change simulation for target, filter, mode, and app-intent changes +- 🔭 Capture-and-compare drift monitoring with approved baselines, risk classification, audit attribution, JSON Lines, and webhooks +- 🏢 Multi-tenant fleet scans with tenant isolation, shared governance rules, and continue-on-error behavior +- 🔑 Capability-based least-privilege authentication (`Core`, `Applications`, `Devices`, `Scripts`, `CloudPC`, `ScopeTags`, `Audit`, or `Full`) +- 🚚 Assignment delivery-health correlation with explicit per-workload partial coverage +- 👮 Intune RBAC and scope-boundary analysis +- 🧪 Tenant-wide assignment-filter governance +- ⚙️ Resumable, budgeted high-scale scans with durable checkpoints, request caching, diagnostics, and a declarative workload registry +- 📐 Versioned JSON Schemas and deterministic v2 assignment records with v1 migration - 🔍 Check assignments for users, groups, and devices - 📱 View all 'All User' and 'All Device' assignments - 🎯 See Intune assignment filters (name and Include/Exclude type) inline on every assignment, in the console, CSV exports, and HTML reports @@ -130,8 +157,9 @@ Your Entra ID application registration needs these permissions: | DeviceManagementScripts.Read.All | Application | Read device management and health scripts | | 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 | +| DeviceManagementServiceConfig.Read.All | Application | Read Autopilot deployment profiles and enrollment status page configurations | -For interactive authentication, IntuneAssignmentChecker automatically requests the delegated versions of these permissions during sign-in. Administrator consent is still required. +For interactive authentication, IntuneAssignmentChecker automatically requests the delegated versions of these permissions during sign-in. Administrator consent is still required. The `Core` capability includes `DeviceManagementServiceConfig.Read.All` because Autopilot and enrollment status page profiles are part of the standard assignment scan. 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. @@ -141,7 +169,9 @@ For certificate, client secret, managed identity, or pre-fetched token authentic > **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. +The automated setup script accepts the same capability profiles as +`Connect-IntuneAssignmentChecker`, so it only registers the selected application +permissions. For example: `./Register-IntuneAssignmentCheckerApp.ps1 -Capability Core,Applications,Audit`. ### Microsoft Graph API behavior @@ -332,9 +362,13 @@ Entra ID → App registrations → Your App → API permissions → "Grant admin The module can be used in two ways: -1. **Interactive Mode**: Menu-driven interface for manual exploration (`IntuneAssignmentChecker`) +1. **Terminal UI**: Full exported-command parity (`Start-IntuneAssignmentCheckerTui`) 2. **Cmdlet Mode**: Individual cmdlets for automation and scripting +The TUI is metadata-driven: it reads the same parameter sets, validation choices, +and help used by direct PowerShell calls. `Get-IntuneAssignmentOperation` exposes +that catalog for testing and integrations. + ### 🖥️ Cmdlet Reference Connect once, then call any cmdlet: @@ -413,6 +447,28 @@ Export-IntuneAssignmentSnapshot -Path 'C:\IntuneSnapshots\assignments.json' -For Compare-IntuneAssignmentSnapshot ` -ReferencePath 'C:\IntuneSnapshots\baseline.json' ` -DifferencePath 'C:\IntuneSnapshots\latest.json' + +# Evaluate the built-in governance pack (or provide -RulePath/-WaiverPath) +Test-IntuneAssignmentGovernance -FailOnSeverity High -SetExitCode + +# Prove the blast radius of a proposed change without writing to Intune +Get-Content ./records.json | ConvertFrom-Json | + Test-IntuneAssignmentChange -ChangeType AddAssignment -PolicyId '' ` + -TargetType AllDevices -Intent required + +# Capture current state, classify drift, and correlate matching Intune audit events +Get-IntuneAssignmentDrift -BaselinePath ./baseline.json -IncludeAuditAttribution ` + -OutputPath ./drift.jsonl -OutputFormat JsonLines + +# Run the workload registry with a time budget and resumable checkpoint +Invoke-IntuneAssignmentScan -ScanBudgetSeconds 900 -CheckpointPath ./scan.json -KeepCheckpoint + +# Correlate targeting with workload delivery status and transparent coverage records +Get-IntuneAssignmentHealth -Workload DeviceConfiguration,Compliance,Applications + +# Audit RBAC boundaries and assignment-filter hygiene +Get-IntuneAssignmentAccess +Test-IntuneAssignmentFilterSet ``` `Get-IntuneUserAssignment`, `Get-IntuneGroupAssignment`, @@ -421,7 +477,7 @@ Compare-IntuneAssignmentSnapshot ` `Get-IntuneUnassignedPolicy`, `Get-IntuneEffectiveAssignment`, and `Search-IntunePolicy` support `-PassThru`. Using it also suppresses the interactive CSV-export prompt. Each object has the type name -`IntuneAssignmentChecker.AssignmentRecord` and schema version `1`. The stable +`IntuneAssignmentChecker.AssignmentRecord` and schema version `2`. The stable contract includes tenant and subject metadata, policy/category/platform, scope tags, assignment target and include/exclude mode, application intent, assignment filter metadata, the display reason, and source command. Console messages remain @@ -459,7 +515,8 @@ assignments. CSV rows also expose the final `DecisionCode`; inspect the full JSO `ReasonChain` for every target, filter, and precedence decision. Assignment snapshots use the `IntuneAssignmentChecker.AssignmentSnapshot` schema -version `1`. They contain the UTC capture time, module version, tenant identity, +version `2`. Version 1 records and snapshots are migrated in memory by +`ConvertTo-IntuneAssignmentRecord` and the snapshot reader. Snapshots contain the UTC capture time, module version, tenant identity, per-category coverage and errors, and canonical assignment records sorted by a stable identity key. Only the documented canonical fields are serialized; arbitrary properties such as access tokens or client secrets are discarded. Apps are covered @@ -536,7 +593,20 @@ Available cmdlets: | `Search-IntunePolicy` | Reverse lookup: find all assignment targets for a policy name | | `Search-IntuneSetting` | Search configured settings across all policies | | `Update-IntuneSettingDefinition` | Refresh the local Settings Catalog definition cache | -| `Invoke-IntuneAssignmentChecker` | Launch the interactive menu (aliased as `IntuneAssignmentChecker`) | +| `Start-IntuneAssignmentCheckerTui` | Launch the metadata-driven terminal UI with parity across module commands | +| `Switch-IntuneAssignmentCheckerTenant` | Clear tenant-scoped state and connect the TUI or shell to another tenant | +| `Get-IntuneAssignmentOperation` | Return the operation and parameter catalog used by the TUI | +| `Invoke-IntuneAssignmentScan` | Run a budgeted, checkpointed assignment scan with coverage diagnostics | +| `Test-IntuneAssignmentGovernance` | Evaluate assignment policy-as-code rules and waivers | +| `Test-IntuneAssignmentChange` | Simulate a proposed assignment change without Graph writes | +| `Get-IntuneAssignmentDrift` | Capture, compare, classify, and optionally attribute assignment drift | +| `Invoke-IntuneAssignmentFleetScan` | Apply scans and governance across isolated tenant configurations | +| `Get-IntuneAssignmentHealth` | Correlate targeting with reported delivery health and coverage | +| `Get-IntuneAssignmentAccess` | Explain Intune RBAC role, scope, and policy boundaries | +| `Test-IntuneAssignmentFilterSet` | Audit unused, orphaned, duplicate, invalid, and ineffective filters | +| `Test-IntuneAssignmentCheckerEnvironment` | Validate authentication, capabilities, beta workloads, paging, and output paths | +| `ConvertTo-IntuneAssignmentRecord` | Migrate v1 records into the canonical v2 schema | +| `Invoke-IntuneAssignmentChecker` | Launch the terminal UI (aliased as `IntuneAssignmentChecker`) | Common parameters on assignment cmdlets: @@ -558,114 +628,26 @@ Common parameters on `Connect-IntuneAssignmentChecker`: | `-ClientSecretCredential`| PSCredential with the App ID as username and the client secret as password (preferred over `-ClientSecret`) | | `-AccessToken` | Pre-fetched Microsoft Graph access token (SecureString), for managed identities or token reuse | | `-Environment` | Environment (Global, USGov, USGovDoD) - defaults to Global | - -### 📋 Interactive Menu Options - -Running `IntuneAssignmentChecker` opens a menu-driven interface with the following options: - -### 🎯 Assignment Checks - -1. **Check User(s) Assignments** - - - View all policies and apps assigned to specific users - - Supports checking multiple users (comma-separated) - - Shows direct and group-based assignments - -2. **Check Group(s) Assignments** - - - View all policies and apps assigned to specific groups - - Supports checking multiple groups - - Shows assignment types (Include/Exclude) - - Recognizes Microsoft 365, security, mail-enabled security, and distribution groups - - Shows group type, assigned/dynamic membership, and mail address in the console and CSV export - - Covers Intune policy and app assignments only; Exchange, Teams, SharePoint, and other Microsoft 365 service policies/content are outside this module's scope - -3. **Check Device(s) Assignments** - - View all policies and apps assigned to specific devices - - Supports checking multiple devices - - Shows inherited assignments from device groups - -### 📋 Policy Overview - -4. **Show All Policies and Their Assignments** - - - Comprehensive view of all Intune policies - - Grouped by policy type and platform - - Includes assignment details - -5. **Show All 'All Users' Assignments** - - - Lists policies assigned to all users - - Includes apps and configurations - - Helps identify broad-scope policies - -6. **Show All 'All Devices' Assignments** - - Lists policies assigned to all devices - - Shows platform-specific assignments - - Identifies universal device policies - -### ⚙️ Advanced Options - -7. **Generate HTML Report** - - - Creates interactive HTML report - - Includes charts and graphs - - Filterable tables with search functionality - - Dark/Light mode toggle - - Export capabilities to Excel/CSV - -8. **Show Policies Without Assignments** - - - Identifies unassigned policies - - Grouped by policy type - - Helps clean up unused policies - -9. **Check for Empty Groups in Assignments** - - Finds assignments to empty groups - - Helps identify ineffective policies - - Supports CSV export of findings - -10. **Compare Assignments Between Groups** - - - Compare policy and app assignments between two or more groups - - Highlights differences and overlaps - - Useful for auditing group consistency - -11. **Show All Failed Assignments** - - - Displays all failed policy deployment assignments - - Helps identify configuration issues - - Supports CSV export of findings - -12. **Simulate Group Membership Impact (User and/or Device)** - - - Preview what policies and apps a user and/or device would receive if added to a group - - Shows deltas vs. the current assignments - - Useful for validating planned group changes before applying them - -13. **Simulate Removing from Group (User and/or Device)** - - - Preview what policies and apps a user and/or device would lose if removed from a group - - Helps evaluate the impact of offboarding or group cleanup - -14. **Search Policy Assignments** - - - Reverse lookup: search by policy name and see every assignment target - - Works across Configuration Profiles, Compliance, Apps, and Endpoint Security - -15. **Search for Specific Settings** - - - Search 17,000+ setting definitions across Settings Catalog and Endpoint Security policies - - Shows which policies configure a given setting and the configured value - - Supports abbreviation expansion and fuzzy matching - -### 🛠️ System Options - -- **[T] Switch Tenant**: Disconnect and connect to a different tenant without restarting -- **[0] Exit**: Safely disconnect and close -- **[98] Support the Project / [99] Report a Bug**: Opens the matching GitHub page - -All operations support CSV export for detailed analysis and reporting. +| `-Capability` | Least-privilege capability profiles to request; defaults to `Full` | +| `-SkipPermissionPrompt` | Continue non-interactively while reporting unavailable capabilities | +| `-PassThru` | Return structured connection and capability status | + +### 📋 Terminal UI controls + +Run `Start-IntuneAssignmentCheckerTui` (or the `IntuneAssignmentChecker` alias +after connecting). The UI groups every exported operation by purpose and shows its +actual PowerShell help, capability profile, parameter sets, mandatory parameters, +switches, and validation choices. + +- Use Up/Down or J/K to navigate, Page Up/Page Down to jump, and Enter to run an operation. +- Press `/` to filter by command, category, synopsis, or capability. +- Press `C` to disconnect and open the tenant-switch connection command, `?` for help, or `Q` to quit. +- Enter comma-separated array values or `@path-to-json` for structured arrays. +- Credentials and secure strings use PowerShell's protected input prompts. + +Because this list is generated from exported command metadata, adding a public +module command automatically adds it to the TUI and to the parity test. There is +no second feature implementation to maintain. ## 🏃‍♂️ Example Runbook diff --git a/Register-IntuneAssignmentCheckerApp.ps1 b/Register-IntuneAssignmentCheckerApp.ps1 index 855091b..a7f5b4c 100644 --- a/Register-IntuneAssignmentCheckerApp.ps1 +++ b/Register-IntuneAssignmentCheckerApp.ps1 @@ -10,8 +10,8 @@ .DESCRIPTION This script fully automates the creation of an Azure AD App Registration for use with Microsoft Intune Graph API queries. - It assigns the required Microsoft Graph permissions, generates a self-signed certificate, creates a temporary Client Secret - (as a workaround to allow certificate injection via Graph API), uploads the certificate as KeyCredential, removes the Client Secret, + It assigns the required Microsoft Graph permissions, generates a self-signed certificate, creates a temporary Client Secret + (as a workaround to allow certificate injection via Graph API), uploads the certificate as KeyCredential, removes the Client Secret, and finally exports the certificate to disk for later use with client credentials authentication. The script uses Update-MgApplication for certificate injection to avoid common Graph SDK permission issues. @@ -24,6 +24,13 @@ Many thanks for sharing this great tool - big shoutout to the IT community! #> +[CmdletBinding()] +param( + [Parameter()] + [ValidateSet('Core', 'Applications', 'Devices', 'Scripts', 'CloudPC', 'ScopeTags', 'Audit', 'Full')] + [string[]]$Capability = @('Full') +) + # STEP 1: Connect to Microsoft Graph and get tenant information Import-Module Microsoft.Graph Connect-MgGraph -Scopes "Application.ReadWrite.All", "Directory.Read.All" -NoWelcome @@ -42,19 +49,35 @@ Write-Host "Short Tenant Name: $shortTenantName" -ForegroundColor Green # STEP 2: Define required permissions $graphAppId = "00000003-0000-0000-c000-000000000000" -$permissions = @( +$permissionCatalog = @( @{ id = "df021288-bdef-4463-88db-98f22de89214"; displayName = "User.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" }, @{ id = "2f51be20-0bb4-4fed-bf7b-db946066c75e"; displayName = "DeviceManagementManagedDevices.Read.All" }, - @{ id = "06a5fe6d-c49d-46a7-b082-56b1b14103c7"; displayName = "DeviceManagementServiceConfig.Read.All" }, @{ id = "c7a5be92-2b3d-4540-8a67-c96dcaae8b43"; displayName = "DeviceManagementScripts.Read.All" }, @{ id = "a9e09520-8ed4-4cde-838e-4fdea192c227"; displayName = "CloudPC.Read.All" }, @{ id = "58ca0d9a-1575-47e1-a3cb-007ef2e4583b"; displayName = "DeviceManagementRBAC.Read.All" } + @{ id = "06a5fe6d-c49d-46a7-b082-56b1b14103c7"; displayName = "DeviceManagementServiceConfig.Read.All" } ) +$capabilityPermissions = [ordered]@{ + Core = @('User.Read.All', 'GroupMember.Read.All', 'DeviceManagementConfiguration.Read.All', 'DeviceManagementServiceConfig.Read.All') + Applications = @('DeviceManagementApps.Read.All') + Devices = @('DeviceManagementManagedDevices.Read.All', 'Device.Read.All') + Scripts = @('DeviceManagementScripts.Read.All') + CloudPC = @('CloudPC.Read.All') + ScopeTags = @('DeviceManagementRBAC.Read.All') + Audit = @('DeviceManagementApps.Read.All') +} +$selectedCapabilities = if ($Capability -contains 'Full') { @($capabilityPermissions.Keys) } else { @($Capability | Select-Object -Unique) } +$selectedPermissionNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($selectedCapability in $selectedCapabilities) { + foreach ($permissionName in $capabilityPermissions[$selectedCapability]) { [void]$selectedPermissionNames.Add($permissionName) } +} +$permissions = @($permissionCatalog | Where-Object { $selectedPermissionNames.Contains($_.displayName) }) + $requiredResourceAccess = @( @{ resourceAppId = $graphAppId @@ -68,13 +91,14 @@ $requiredResourceAccess = @( $appDisplayName = "Intune Assignment Checker [$shortTenantName]" $app = New-MgApplication -DisplayName $appDisplayName -SignInAudience AzureADMyOrg -RequiredResourceAccess $requiredResourceAccess Write-Host "App Registration created: AppId: $($app.AppId)" -ForegroundColor Green +Write-Host "Capabilities: $($selectedCapabilities -join ', ')" -ForegroundColor Green $sp = New-MgServicePrincipal -AppId $app.AppId Write-Host "Service Principal created: ObjectId: $($sp.Id)" -ForegroundColor Green # STEP 4: Create Temporary Client Secret (workaround) $passwordCred = @{ "displayName" = "TemporaryClientSecret"; "endDateTime" = (Get-Date).AddHours(1) } -$clientSecret = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $passwordCred +$null = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $passwordCred Write-Host "Temporary Client Secret created. Will be removed after certificate upload." -ForegroundColor Green # STEP 5: Generate and upload self-signed certificate @@ -113,9 +137,9 @@ catch { # STEP 6: Remove Temporary Client Secret $passwords = (Get-MgApplication -ApplicationId $app.Id).PasswordCredentials -foreach ($pwd in $passwords) { - if ($pwd.DisplayName -eq "TemporaryClientSecret") { - Remove-MgApplicationPassword -ApplicationId $app.Id -KeyId $pwd.KeyId +foreach ($passwordCredential in $passwords) { + if ($passwordCredential.DisplayName -eq "TemporaryClientSecret") { + Remove-MgApplicationPassword -ApplicationId $app.Id -KeyId $passwordCredential.KeyId Write-Host "Temporary Client Secret removed." -ForegroundColor Green } } @@ -140,6 +164,6 @@ $appId = $app.AppId Write-Host "`n----------------------------" -ForegroundColor Cyan Write-Host "You can now connect IntuneAssignmentChecker with the following command:" -ForegroundColor Cyan -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 "Connect-IntuneAssignmentChecker -AppId `"$appId`" -TenantId `"$tenantId`" -CertificateThumbprint `"$certificateThumbprint`" -Capability $($selectedCapabilities -join ',')" -ForegroundColor Yellow +Write-Host "Afterwards, run 'Start-IntuneAssignmentCheckerTui' for the terminal UI or call any exported command directly." -ForegroundColor Cyan Write-Host "----------------------------" diff --git a/Tests/Release/ModulePackage.Tests.ps1 b/Tests/Release/ModulePackage.Tests.ps1 index 977c0f9..cfef2ef 100644 --- a/Tests/Release/ModulePackage.Tests.ps1 +++ b/Tests/Release/ModulePackage.Tests.ps1 @@ -67,4 +67,11 @@ Describe 'IntuneAssignmentChecker release package' { (Get-Command IntuneAssignmentChecker -CommandType Alias).Definition | Should -BeExactly Invoke-IntuneAssignmentChecker } + + It 'keeps the Windows distribution PowerShell-native' { + Test-Path -LiteralPath (Join-Path $repoRoot 'packaging/IntuneAssignmentChecker.wxs') -PathType Leaf | Should -BeTrue + Test-Path -LiteralPath (Join-Path $repoRoot 'packaging/Build-WindowsInstaller.ps1') -PathType Leaf | Should -BeTrue + @(Get-ChildItem $repoRoot -Recurse -File -Include '*.csproj', '*.cs', '*.exe').Count | Should -Be 0 + (Get-Content (Join-Path $repoRoot 'packaging/README.md') -Raw) | Should -Match 'does not compile or wrap' + } } diff --git a/Tests/Unit/AssignmentRecord.Tests.ps1 b/Tests/Unit/AssignmentRecord.Tests.ps1 index 0b455a7..e85a415 100644 --- a/Tests/Unit/AssignmentRecord.Tests.ps1 +++ b/Tests/Unit/AssignmentRecord.Tests.ps1 @@ -27,14 +27,18 @@ BeforeAll { } Describe 'IntuneAssignmentChecker.AssignmentRecord' { - It 'pins schema version 1, property order, and the PowerShell type name' { + It 'pins schema version 2, property order, and the PowerShell type name' { $record = New-IACAssignmentRecord -CategoryId Configuration -Category 'Configuration Policy' -PolicyId policy-1 -PolicyName Baseline -AssignmentMode Include -TargetType AllDevices -TargetName 'All Devices' $record.PSObject.TypeNames[0] | Should -BeExactly 'IntuneAssignmentChecker.AssignmentRecord' - $record.SchemaVersion | Should -Be 1 + $record.SchemaName | Should -BeExactly 'IntuneAssignmentChecker.AssignmentRecord' + $record.SchemaVersion | Should -Be 2 + $record.RecordId | Should -Match '^[a-f0-9]{64}$' + $record.GraphApiVersion | Should -BeExactly beta $record.TenantId | Should -BeExactly 'tenant-1' @($record.PSObject.Properties.Name) | Should -Be @( - 'SchemaVersion', 'TenantId', 'TenantName', 'SubjectType', 'SubjectId', 'SubjectName', + 'SchemaName', 'SchemaVersion', 'RecordId', 'GraphApiVersion', + 'TenantId', 'TenantName', 'SubjectType', 'SubjectId', 'SubjectName', 'CategoryId', 'Category', 'PolicyId', 'PolicyName', 'Platform', 'ScopeTagIds', 'ScopeTags', 'AssignmentId', 'AssignmentMode', 'TargetType', 'TargetId', 'TargetName', 'Intent', 'FilterId', 'FilterName', 'FilterMode', 'FilterRule', 'FilterPlatform', diff --git a/Tests/Unit/AssignmentSnapshot.Tests.ps1 b/Tests/Unit/AssignmentSnapshot.Tests.ps1 index c3a6675..3723056 100644 --- a/Tests/Unit/AssignmentSnapshot.Tests.ps1 +++ b/Tests/Unit/AssignmentSnapshot.Tests.ps1 @@ -60,10 +60,10 @@ Describe 'Export-IntuneAssignmentSnapshot' { $snapshot.PSObject.TypeNames | Should -Contain IntuneAssignmentChecker.AssignmentSnapshot $loaded.SchemaName | Should -BeExactly IntuneAssignmentChecker.AssignmentSnapshot - $loaded.SchemaVersion | Should -Be 1 + $loaded.SchemaVersion | Should -Be 2 $loaded.CapturedAtUtc.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ') | Should -BeExactly '2026-08-01T10:00:00.0000000Z' - $loaded.ModuleVersion | Should -BeExactly '4.4.0' + $loaded.ModuleVersion | Should -BeExactly '5.0.0' $loaded.Tenant.Id | Should -BeExactly tenant-1 $loaded.Coverage.Complete | Should -BeTrue $loaded.Coverage.RecordCount | Should -Be 1 @@ -75,6 +75,32 @@ Describe 'Export-IntuneAssignmentSnapshot' { @($loaded.Records[0].ScopeTags).Count | Should -Be 0 } + It 'migrates a v1 snapshot and validates the migrated document against the v2 schema' { + $path = Join-Path $TestDrive 'snapshot-v1.json' + $record = New-SnapshotTestRecord + $record | Export-IntuneAssignmentSnapshot -Path $path -CoverageCategory DeviceConfigurations ` + -CoverageComplete -CapturedAtUtc $script:fixedCapture + $document = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 30 + $document.SchemaVersion = 1 + $document.Records[0].SchemaVersion = 1 + foreach ($propertyName in @('SchemaName', 'RecordId', 'GraphApiVersion')) { + $document.Records[0].PSObject.Properties.Remove($propertyName) + } + [IO.File]::WriteAllText($path, ($document | ConvertTo-Json -Depth 30)) + + $migrated = Read-IACAssignmentSnapshot -Path $path + $schemaPath = Join-Path $moduleRoot 'Schemas/assignment-snapshot.v2.schema.json' + $snapshotSchema = Get-Content -LiteralPath $schemaPath -Raw | ConvertFrom-Json -Depth 40 + $recordSchema = Get-Content -LiteralPath (Join-Path $moduleRoot 'Schemas/assignment-record.v2.schema.json') -Raw | ConvertFrom-Json -Depth 40 + $snapshotSchema.properties.Records.items = $recordSchema + + $migrated.SchemaVersion | Should -Be 2 + $migrated.MigratedFromSchemaVersion | Should -Be 1 + $migrated.Records[0].SchemaVersion | Should -Be 2 + $migrated.Records[0].RecordId | Should -Match '^[0-9a-f]{64}$' + Test-Json -Json ($migrated | ConvertTo-Json -Depth 30) -Schema ($snapshotSchema | ConvertTo-Json -Depth 40) | Should -BeTrue + } + It 'reads the installed version without validating external module dependencies' { Mock Get-Module { $null } Mock Import-PowerShellDataFile { @{ ModuleVersion = '9.8.7' } } -ParameterFilter { @@ -435,13 +461,13 @@ Describe 'Compare-IntuneAssignmentSnapshot' { $nonNumericVersionPath = Join-Path $TestDrive 'non-numeric-version.json' $missingRecordsPath = Join-Path $TestDrive 'missing-records.json' [System.IO.File]::WriteAllText($malformedPath, '{not-json') - [System.IO.File]::WriteAllText($unsupportedPath, '{"SchemaName":"IntuneAssignmentChecker.AssignmentSnapshot","SchemaVersion":2}') + [System.IO.File]::WriteAllText($unsupportedPath, '{"SchemaName":"IntuneAssignmentChecker.AssignmentSnapshot","SchemaVersion":3}') [System.IO.File]::WriteAllText($nonNumericVersionPath, '{"SchemaName":"IntuneAssignmentChecker.AssignmentSnapshot","SchemaVersion":"v1"}') [System.IO.File]::WriteAllText($missingRecordsPath, '{"SchemaName":"IntuneAssignmentChecker.AssignmentSnapshot","SchemaVersion":1,"CapturedAtUtc":"2026-08-01T10:00:00Z","ModuleVersion":"4.4.0","Tenant":{},"Coverage":{}}') { Read-IACAssignmentSnapshot -Path $malformedPath } | Should -Throw '*not valid JSON*' - { Read-IACAssignmentSnapshot -Path $unsupportedPath } | Should -Throw '*unsupported schema version*expected version 1*' - { Read-IACAssignmentSnapshot -Path $nonNumericVersionPath } | Should -Throw '*unsupported schema version*expected version 1*' + { Read-IACAssignmentSnapshot -Path $unsupportedPath } | Should -Throw '*unsupported schema version*expected version 1 or 2*' + { Read-IACAssignmentSnapshot -Path $nonNumericVersionPath } | Should -Throw '*unsupported schema version*expected version 1 or 2*' { Read-IACAssignmentSnapshot -Path $missingRecordsPath } | Should -Throw "*missing 'Records'*" } diff --git a/Tests/Unit/GraphTransport.Tests.ps1 b/Tests/Unit/GraphTransport.Tests.ps1 index 75550b1..39ad5d1 100644 --- a/Tests/Unit/GraphTransport.Tests.ps1 +++ b/Tests/Unit/GraphTransport.Tests.ps1 @@ -83,6 +83,14 @@ Describe 'Get-IntuneEntities optional workload diagnostics' { Should -Invoke Write-Warning -Exactly 0 } + + It 'throws for coverage-aware callers instead of converting failure to an empty workload' { + Mock Invoke-IACGraphRequest { throw 'HTTP 403 Forbidden' } + + { Get-IntuneEntities -EntityType 'deviceConfigurations' -ThrowOnError } | + Should -Throw '*403*' + Should -Invoke Write-Warning -Exactly 0 + } } Describe 'Invoke-IACGraphRequest' { @@ -153,6 +161,26 @@ Describe 'Invoke-IACGraphRequest' { Should -Invoke Invoke-MgGraphRequest -Exactly 2 } + It 'returns only the first response when FirstPageOnly is requested' { + Mock Invoke-MgGraphRequest { + @{ value = @([PSCustomObject]@{ id = 'one' }); '@odata.nextLink' = 'https://graph.test/beta/groups?$skiptoken=next' } + } + + $response = Invoke-IACGraphRequest -Uri '/groups?$top=1' -FirstPageOnly + + $response.value.id | Should -BeExactly 'one' + $response.'@odata.nextLink' | Should -Not -BeNullOrEmpty + Should -Invoke Invoke-MgGraphRequest -Exactly 1 + } + + It 'rejects conflicting paging modes' { + Mock Invoke-MgGraphRequest + + { Invoke-IACGraphRequest -Uri '/groups' -AllPages -FirstPageOnly } | + Should -Throw '*cannot be used together*' + Should -Invoke Invoke-MgGraphRequest -Exactly 0 + } + It 'retries transient responses and succeeds' { $throttled = $transportFixture.errors.throttled Mock Invoke-MgGraphRequest { diff --git a/Tests/Unit/V5Platform.Tests.ps1 b/Tests/Unit/V5Platform.Tests.ps1 new file mode 100644 index 0000000..80f0f1d --- /dev/null +++ b/Tests/Unit/V5Platform.Tests.ps1 @@ -0,0 +1,413 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $manifestPath = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1' + Import-Module $manifestPath -Force +} + +AfterAll { + Remove-Module IntuneAssignmentChecker -Force -ErrorAction SilentlyContinue +} + +Describe 'v5 terminal UI parity' { + It 'discovers every exported operation except its own catalog and UI infrastructure' { + $module = Get-Module IntuneAssignmentChecker + $expected = @($module.ExportedFunctions.Keys | Where-Object { + $_ -notin @('Get-IntuneAssignmentOperation', 'Invoke-IntuneAssignmentChecker', 'Start-IntuneAssignmentCheckerTui') + } | Sort-Object) + $actual = @((Get-IntuneAssignmentOperation).Name | Sort-Object) + + $actual | Should -Be $expected + } + + It 'exposes parameter sets and editable parameter metadata from the real command' { + $operation = Get-IntuneAssignmentOperation -Name Test-IntuneAssignmentGovernance + + $operation.ParameterSets.Count | Should -BeGreaterThan 1 + @($operation.ParameterSets.Parameters.Name) | Should -Contain 'SnapshotPath' + @($operation.ParameterSets.Parameters.Name) | Should -Contain 'FailOnSeverity' + } + + It 'accepts multiple ValidateSet choices for array parameters' { + Mock Read-Host -ModuleName IntuneAssignmentChecker { 'Core,Audit' } + Mock Write-Host -ModuleName IntuneAssignmentChecker {} + $parameter = [PSCustomObject]@{ + Name = 'Capability'; Type = 'System.String[]'; TypeName = 'String[]'; Mandatory = $false + ValidateSet = @('Core', 'Audit', 'Full'); IsSwitch = $false; IsArray = $true + HelpMessage = $null + } + + $value = & (Get-Module IntuneAssignmentChecker) { + param($Parameter) + Read-IACTuiParameterValue -Parameter $Parameter -CommandName Connect-IntuneAssignmentChecker + } $parameter + + $value.Supplied | Should -BeTrue + $value.Value | Should -Be @('Core', 'Audit') + } + + It 'uses concise descriptions and parameter help instead of generated syntax' { + $operations = @(Get-IntuneAssignmentOperation) + @($operations | Where-Object { $_.Synopsis -match '[\r\n]' -or $_.Synopsis.StartsWith($_.Name) }).Count | Should -Be 0 + $governance = $operations | Where-Object Name -EQ Test-IntuneAssignmentGovernance + $snapshotParameter = @($governance.ParameterSets.Parameters | Where-Object Name -EQ SnapshotPath)[0] + $snapshotParameter.HelpMessage | Should -Match 'snapshot' + } +} + +Describe 'v5 capability profiles' { + It 'resolves Core without unrelated optional permissions' { + $permissions = @(& (Get-Module IntuneAssignmentChecker) { + @(Resolve-IACCapabilityPermission -Capability Core).Permission + }) + + $permissions | Should -Contain 'User.Read.All' + $permissions | Should -Contain 'GroupMember.Read.All' + $permissions | Should -Contain 'DeviceManagementConfiguration.Read.All' + $permissions | Should -Contain 'DeviceManagementServiceConfig.Read.All' + $permissions | Should -Not -Contain 'CloudPC.Read.All' + $permissions | Should -Not -Contain 'DeviceManagementApps.Read.All' + } + + It 'reports requested, unavailable, and skipped capability states' { + $states = @(& (Get-Module IntuneAssignmentChecker) { + $script:RequestedCapabilities = @('Core') + Get-IACCapabilityStatus -GrantedPermission @('User.Read.All') + }) + + ($states | Where-Object Name -EQ Core).Status | Should -BeExactly 'Unavailable' + ($states | Where-Object Name -EQ Core).MissingPermissions | Should -Contain 'GroupMember.Read.All' + ($states | Where-Object Name -EQ CloudPC).Status | Should -BeExactly 'Skipped' + } +} + +Describe 'v5 governance and simulation objects' { + BeforeAll { + $script:testGovernanceRecord = [PSCustomObject]@{ + SchemaName = 'IntuneAssignmentChecker.AssignmentRecord'; SchemaVersion = 2 + PolicyId = 'app-1'; PolicyName = 'Required App'; CategoryId = 'Applications' + AssignmentId = 'assignment-1'; AssignmentMode = 'Include'; TargetType = 'AllUsers' + TargetId = $null; TargetName = 'All Users'; Intent = 'required'; ReasonChain = @() + } + } + + It 'emits stable evidence-bearing governance findings' { + $findings = @($script:testGovernanceRecord | Test-IntuneAssignmentGovernance) + + $findings.RuleId | Should -Contain 'IAC001' + $findings.RuleId | Should -Contain 'IAC003' + $findings[0].FindingId | Should -Match '^[0-9a-f]{24}$' + $findings[0].Remediation | Should -Not -BeNullOrEmpty + } + + It 'validates canonical records and findings against the shipped JSON Schemas' { + $recordSchema = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker/Schemas/assignment-record.v2.schema.json' + $findingSchema = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker/Schemas/governance-finding.v1.schema.json' + $canonicalRecord = & (Get-Module IntuneAssignmentChecker) { + $script:CurrentTenantId = 'tenant-1' + New-IACAssignmentRecord -CategoryId Applications -Category Applications ` + -PolicyId app-1 -PolicyName 'Required App' -AssignmentMode Include -TargetType AllUsers + } + $finding = @($canonicalRecord | Test-IntuneAssignmentGovernance)[0] + + Test-Json -Json ($canonicalRecord | ConvertTo-Json -Depth 20) -SchemaFile $recordSchema | Should -BeTrue + Test-Json -Json ($finding | ConvertTo-Json -Depth 20) -SchemaFile $findingSchema | Should -BeTrue + } + + It 'models a broad required assignment without performing a write' { + $simulation = $script:testGovernanceRecord | Test-IntuneAssignmentChange -ChangeType AddAssignment -PolicyId app-1 -TargetType AllDevices -Intent required + + $simulation.ReadOnly | Should -BeTrue + $simulation.Risk | Should -BeExactly 'Critical' + $simulation.After.TargetType | Should -BeExactly 'AllDevices' + $simulation.After.ReasonChain[-1].Message | Should -Match 'no Microsoft Graph write' + } + + It 'suppresses active waivers, restores expired findings, and rejects invalid expiry values' { + $activePath = Join-Path $TestDrive 'active-waiver.json' + $expiredPath = Join-Path $TestDrive 'expired-waiver.json' + $invalidPath = Join-Path $TestDrive 'invalid-waiver.json' + $baseWaiver = [ordered]@{ RuleId = 'IAC001'; PolicyId = 'app-1'; Owner = 'security@example.test'; Justification = 'Approved test'; ExpiresAtUtc = [datetimeoffset]::UtcNow.AddDays(1).ToString('o') } + [IO.File]::WriteAllText($activePath, (@{ Waivers = @([PSCustomObject]$baseWaiver) } | ConvertTo-Json -Depth 10)) + $expired = [ordered]@{} + $baseWaiver; $expired.ExpiresAtUtc = [datetimeoffset]::UtcNow.AddDays(-1).ToString('o') + [IO.File]::WriteAllText($expiredPath, (@{ Waivers = @([PSCustomObject]$expired) } | ConvertTo-Json -Depth 10)) + $invalid = [ordered]@{} + $baseWaiver; $invalid.ExpiresAtUtc = 'not-a-date' + [IO.File]::WriteAllText($invalidPath, (@{ Waivers = @([PSCustomObject]$invalid) } | ConvertTo-Json -Depth 10)) + + $active = @($script:testGovernanceRecord | Test-IntuneAssignmentGovernance -WaiverPath $activePath -IncludeSuppressed) + $expiredFindings = @($script:testGovernanceRecord | Test-IntuneAssignmentGovernance -WaiverPath $expiredPath -IncludeSuppressed) + + ($active | Where-Object RuleId -EQ IAC001).Suppressed | Should -BeTrue + ($expiredFindings | Where-Object RuleId -EQ IAC001).Suppressed | Should -BeFalse + { $script:testGovernanceRecord | Test-IntuneAssignmentGovernance -WaiverPath $invalidPath } | Should -Throw '*not a valid UTC date-time*' + } +} + +Describe 'v5 structured output and drift' { + It 'writes JSON, JSON Lines, and formula-safe CSV from the same object model' { + $item = [PSCustomObject]@{ PolicyName = '=HYPERLINK("https://example.test")'; Evidence = @{ Value = '+SUM(1,1)' }; Count = 2 } + $jsonPath = Join-Path $TestDrive 'result.json' + $jsonLinesPath = Join-Path $TestDrive 'result.jsonl' + $csvPath = Join-Path $TestDrive 'result.csv' + & (Get-Module IntuneAssignmentChecker) { param($Item, $Path) Export-IACStructuredOutput -InputObject @($Item) -Path $Path -Format Json } $item $jsonPath | Out-Null + & (Get-Module IntuneAssignmentChecker) { param($Item, $Path) Export-IACStructuredOutput -InputObject @($Item) -Path $Path -Format JsonLines } $item $jsonLinesPath | Out-Null + & (Get-Module IntuneAssignmentChecker) { param($Item, $Path) Export-IACStructuredOutput -InputObject @($Item) -Path $Path -Format Csv } $item $csvPath | Out-Null + + (Get-Content $jsonPath -Raw | ConvertFrom-Json)[0].PolicyName | Should -BeExactly $item.PolicyName + (Get-Content $jsonLinesPath -Raw | ConvertFrom-Json).Evidence.Value | Should -BeExactly '+SUM(1,1)' + (Import-Csv $csvPath).PolicyName | Should -BeExactly "'$($item.PolicyName)" + } + + It 'classifies broad added assignments and correlates beta audit evidence' { + $baselinePath = Join-Path $TestDrive 'drift-baseline.json' + $currentPath = Join-Path $TestDrive 'drift-current.json' + $record = & (Get-Module IntuneAssignmentChecker) { + $script:CurrentTenantId = 'tenant-1'; $script:CurrentTenantName = 'Tenant One' + New-IACAssignmentRecord -CategoryId Applications -Category Applications -PolicyId app-1 -PolicyName 'Required App' ` + -AssignmentId assignment-1 -AssignmentMode Include -TargetType AllUsers -Intent required + } + Export-IntuneAssignmentSnapshot -Path $baselinePath -InputObject @() -CoverageCategory Applications -CoverageComplete -CapturedAtUtc ([datetimeoffset]'2026-08-01T10:00:00Z') + $record | Export-IntuneAssignmentSnapshot -Path $currentPath -CoverageCategory Applications -CoverageComplete -CapturedAtUtc ([datetimeoffset]'2026-08-01T11:00:00Z') + & (Get-Module IntuneAssignmentChecker) { $script:GraphEndpoint = 'https://graph.microsoft.com' } + Mock Invoke-IACGraphRequest -ModuleName IntuneAssignmentChecker { + @{ value = @([PSCustomObject]@{ id = 'audit-1'; displayName = 'Create assignment'; activityDateTime = '2026-08-01T10:30:00Z'; actor = [PSCustomObject]@{ userPrincipalName = 'admin@example.test' }; resources = @([PSCustomObject]@{ resourceId = 'app-1'; displayName = 'Required App' }) }) } + } + + $event = Get-IntuneAssignmentDrift -BaselinePath $baselinePath -CurrentSnapshotPath $currentPath -IncludeAuditAttribution + + $event.Risk | Should -BeExactly 'Critical' + $event.Attribution | Should -BeExactly 'Correlated' + $event.AuditActor | Should -BeExactly 'admin@example.test' + } + + It 'refuses non-HTTPS drift webhooks before transmitting data' { + $path = Join-Path $TestDrive 'same-snapshot.json' + & (Get-Module IntuneAssignmentChecker) { $script:CurrentTenantId = 'tenant-1'; $script:CurrentTenantName = 'Tenant One' } + Export-IntuneAssignmentSnapshot -Path $path -InputObject @() -CoverageCategory Applications -CoverageComplete -CapturedAtUtc ([datetimeoffset]'2026-08-01T10:00:00Z') + Mock Invoke-RestMethod -ModuleName IntuneAssignmentChecker + + { Get-IntuneAssignmentDrift -BaselinePath $path -CurrentSnapshotPath $path -WebhookUri 'http://example.test/hook' -Confirm:$false } | + Should -Throw '*absolute HTTPS URI*' + Should -Invoke Invoke-RestMethod -ModuleName IntuneAssignmentChecker -Exactly 0 + } +} + +Describe 'v5 delivery health normalization' { + It 'maps live report numeric states and string states consistently' { + $states = & (Get-Module IntuneAssignmentChecker) { + ConvertTo-IACDeliveryState 2 + ConvertTo-IACDeliveryState 3 + ConvertTo-IACDeliveryState conflict + ConvertTo-IACDeliveryState notApplicable + } + $states | Should -Be @('Succeeded', 'Failed', 'Conflict', 'NotApplicable') + } + + It 'maps schema columns to report values by position' { + $row = & (Get-Module IntuneAssignmentChecker) { + $report = [PSCustomObject]@{ + Schema = @([PSCustomObject]@{ Column = 'DeviceId' }, [PSCustomObject]@{ Column = 'PolicyStatus' }) + Values = @(, @('device-1', 2)) + } + ConvertFrom-IACReportRows -Report $report + } + + $row.DeviceId | Should -BeExactly 'device-1' + $row.PolicyStatus | Should -Be 2 + } +} + +Describe 'v5 resumable scan runner' { + It 'writes a token-free category checkpoint and returns run diagnostics' { + $checkpointPath = Join-Path $TestDrive 'scan.json' + & (Get-Module IntuneAssignmentChecker) { + $script:GraphEndpoint = 'https://graph.microsoft.com' + $script:CurrentTenantId = 'tenant-1' + $script:CurrentTenantName = 'Tenant One' + } + Mock Get-IntuneCategoryDefinition -ModuleName IntuneAssignmentChecker { + @( + [PSCustomObject]@{ Id = 'One'; DisplayName = 'One' } + [PSCustomObject]@{ Id = 'Two'; DisplayName = 'Two' } + ) + } + Mock Invoke-IntuneCategoryScan -ModuleName IntuneAssignmentChecker { + [PSCustomObject]@{ Records = @(); Errors = @(); Skipped = @() } + } + + $run = Invoke-IntuneAssignmentScan -CheckpointPath $checkpointPath -KeepCheckpoint + + $run.Complete | Should -BeTrue + $run.Completed | Should -Be @('One', 'Two') + $run.Diagnostics.ProviderCount | Should -Be 2 + Test-Path -LiteralPath $checkpointPath | Should -BeTrue + (Get-Content -LiteralPath $checkpointPath -Raw) | Should -Not -Match '(?i)access.?token|client.?secret|password' + Should -Invoke Invoke-IntuneCategoryScan -ModuleName IntuneAssignmentChecker -Exactly 2 + + $resumed = Invoke-IntuneAssignmentScan -CheckpointPath $checkpointPath -Resume -KeepCheckpoint + $resumed.Complete | Should -BeTrue + $resumed.Completed | Should -Be @('One', 'Two') + Should -Invoke Invoke-IntuneCategoryScan -ModuleName IntuneAssignmentChecker -Exactly 2 + } +} + +Describe 'v5 coverage-aware commands' { + It 'treats optional skipped workloads as transparent but non-blocking coverage' { + $snapshotPath = Join-Path $TestDrive 'optional-skip.json' + $filterPath = Join-Path $TestDrive 'optional-filters.json' + & (Get-Module IntuneAssignmentChecker) { + $script:GraphEndpoint = 'https://graph.microsoft.com' + $script:CurrentTenantId = 'tenant-1' + $script:CurrentTenantName = 'Tenant One' + } + Mock Get-IntuneCategoryDefinition -ModuleName IntuneAssignmentChecker { + @([PSCustomObject]@{ Id = 'CloudPCProvisioningPolicies'; DisplayName = 'Cloud PC Provisioning Policies' }) + } + Mock Get-AssignmentFilterLookup -ModuleName IntuneAssignmentChecker { @{} } + Mock Invoke-IntuneCategoryScan -ModuleName IntuneAssignmentChecker { + [PSCustomObject]@{ + Records = @(); Errors = @() + Skipped = @([PSCustomObject]@{ CategoryId = 'CloudPCProvisioningPolicies'; Message = 'Workload is not licensed.' }) + } + } + Export-IntuneAssignmentSnapshot -Path $snapshotPath -CapturedAtUtc ([datetimeoffset]'2026-08-01T10:00:00Z') + [IO.File]::WriteAllText($filterPath, (@{ Filters = @(@{ Id = 'filter-1'; Name = 'Unused'; Platform = 'windows10AndLater'; Rule = '(device.osVersion -startsWith "10.")'; AssignmentFilterManagementType = 'devices' }) } | ConvertTo-Json -Depth 10)) + + $governance = @(Test-IntuneAssignmentGovernance -SnapshotPath $snapshotPath) + $filters = @(Test-IntuneAssignmentFilterSet -SnapshotPath $snapshotPath -FilterDefinitionPath $filterPath) + $approval = Get-IntuneAssignmentDrift -BaselinePath (Join-Path $TestDrive 'approved-optional.json') ` + -CurrentSnapshotPath $snapshotPath -ApproveBaseline + + $governance.RuleId | Should -Not -Contain IAC007 + $filters.RuleId | Should -Contain IAF001 + $filters.RuleId | Should -Not -Contain IAF007 + $approval.Complete | Should -BeTrue + } + + It 'fails a tenant entry without unattended credentials and continues the fleet result contract' { + $configurationPath = Join-Path $TestDrive 'fleet.json' + [IO.File]::WriteAllText($configurationPath, (@{ schemaVersion = 1; tenants = @(@{ TenantId = 'tenant-1' }) } | ConvertTo-Json -Depth 10)) + Mock Connect-IntuneAssignmentChecker -ModuleName IntuneAssignmentChecker + Mock Disconnect-MgGraph -ModuleName IntuneAssignmentChecker + Mock Read-Host -ModuleName IntuneAssignmentChecker { throw 'Interactive prompt was reached.' } + + $errors = @() + $result = Invoke-IntuneAssignmentFleetScan -ConfigurationPath $configurationPath -ErrorVariable +errors + + $result.Status | Should -BeExactly 'Failed' + $result.Error | Should -Match 'unattended' + Should -Invoke Connect-IntuneAssignmentChecker -ModuleName IntuneAssignmentChecker -Exactly 0 + Should -Invoke Read-Host -ModuleName IntuneAssignmentChecker -Exactly 0 + } + + It 'suppresses unused-filter findings when assignment coverage is incomplete' { + $snapshotPath = Join-Path $TestDrive 'filter-partial.json' + $filterPath = Join-Path $TestDrive 'filters.json' + $record = & (Get-Module IntuneAssignmentChecker) { + $script:CurrentTenantId = 'tenant-1'; $script:CurrentTenantName = 'Tenant One' + New-IACAssignmentRecord -CategoryId Applications -Category Applications -PolicyId app-1 -PolicyName App ` + -AssignmentId assignment-1 -AssignmentMode Include -TargetType Group -TargetId group-1 + } + $coverageError = [PSCustomObject]@{ CategoryId = 'CompliancePolicies'; Message = 'HTTP 403' } + $record | Export-IntuneAssignmentSnapshot -Path $snapshotPath -CoverageCategory Applications,CompliancePolicies -CoverageError $coverageError -CapturedAtUtc ([datetimeoffset]'2026-08-01T10:00:00Z') + [IO.File]::WriteAllText($filterPath, (@{ Filters = @(@{ Id = 'filter-1'; Name = 'Unused'; Platform = 'windows10AndLater'; Rule = '(device.osVersion -startsWith "10.")'; AssignmentFilterManagementType = 'devices' }) } | ConvertTo-Json -Depth 10)) + + $findings = @(Test-IntuneAssignmentFilterSet -SnapshotPath $snapshotPath -FilterDefinitionPath $filterPath) + + $findings.RuleId | Should -Contain IAF007 + $findings.RuleId | Should -Not -Contain IAF001 + } + + It 'collapses broad RBAC access to one result while preserving policy count' { + $snapshotPath = Join-Path $TestDrive 'access.json' + $records = & (Get-Module IntuneAssignmentChecker) { + $script:CurrentTenantId = 'tenant-1'; $script:CurrentTenantName = 'Tenant One' + New-IACAssignmentRecord -CategoryId DeviceConfigurations -Category Configuration -PolicyId policy-1 -PolicyName One -AssignmentMode None -TargetType None + New-IACAssignmentRecord -CategoryId DeviceConfigurations -Category Configuration -PolicyId policy-2 -PolicyName Two -AssignmentMode None -TargetType None + } + $records | Export-IntuneAssignmentSnapshot -Path $snapshotPath -CoverageCategory DeviceConfigurations -CoverageComplete -CapturedAtUtc ([datetimeoffset]'2026-08-01T10:00:00Z') + & (Get-Module IntuneAssignmentChecker) { $script:GraphEndpoint = 'https://graph.microsoft.com'; $script:CurrentTenantId = 'tenant-1' } + Mock Invoke-IACGraphRequest -ModuleName IntuneAssignmentChecker { + if ($Uri -like '*roleDefinitions*') { return @{ value = @([PSCustomObject]@{ id = 'definition-1'; displayName = 'Intune Administrator'; roleAssignments = @([PSCustomObject]@{ id = 'role-1' }) }) } } + @{ value = @([PSCustomObject]@{ id = 'role-1'; displayName = 'Broad administrators'; members = @('group-1'); resourceScopes = @(); roleScopeTagIds = @('0') }) } + } + + $access = @(Get-IntuneAssignmentAccess -SnapshotPath $snapshotPath) + + $access.Count | Should -Be 1 + $access[0].BoundaryStatus | Should -BeExactly 'Broad' + $access[0].MatchingPolicyCount | Should -Be 2 + } + + It 'reports inventory permission failures as failed health coverage' { + & (Get-Module IntuneAssignmentChecker) { $script:GraphEndpoint = 'https://graph.microsoft.com' } + Mock Get-IntuneEntities -ModuleName IntuneAssignmentChecker { throw 'HTTP 403 Forbidden' } + + $health = @(Get-IntuneAssignmentHealth -Workload DeviceConfiguration) + + $health.Count | Should -Be 1 + $health[0].RecordType | Should -BeExactly 'Coverage' + $health[0].CoverageStatus | Should -BeExactly 'Failed' + $health[0].CoverageMessage | Should -Match '403' + } + + It 'keeps legitimate multi-user rows for the same managed device' { + & (Get-Module IntuneAssignmentChecker) { $script:GraphEndpoint = 'https://graph.microsoft.com' } + Mock Get-IntuneEntities -ModuleName IntuneAssignmentChecker { + @([PSCustomObject]@{ id = 'policy-1'; displayName = 'Shared device policy' }) + } + Mock Invoke-IACGraphRequest -ModuleName IntuneAssignmentChecker { @{} } + Mock ConvertFrom-IACReportResponse -ModuleName IntuneAssignmentChecker { @{} } + Mock ConvertFrom-IACReportRows -ModuleName IntuneAssignmentChecker { + @( + [PSCustomObject]@{ IntuneDeviceId = 'device-1'; DeviceName = 'SharedPC'; UPN = 'one@example.test'; PolicyStatus = 2; PspdpuLastModifiedTimeUtc = '2026-08-01T10:00:00Z' } + [PSCustomObject]@{ IntuneDeviceId = 'device-1'; DeviceName = 'SharedPC'; UPN = 'two@example.test'; PolicyStatus = 2; PspdpuLastModifiedTimeUtc = '2026-08-01T10:00:00Z' } + ) + } + + $health = @(Get-IntuneAssignmentHealth -Workload DeviceConfiguration) + + @($health | Where-Object RecordType -EQ Status).Count | Should -Be 2 + ($health | Where-Object RecordType -EQ Coverage).CoverageStatus | Should -BeExactly 'Complete' + } + + It 'stops an exact repeated health row and reports failed coverage' { + & (Get-Module IntuneAssignmentChecker) { $script:GraphEndpoint = 'https://graph.microsoft.com' } + Mock Get-IntuneEntities -ModuleName IntuneAssignmentChecker { + @([PSCustomObject]@{ id = 'policy-1'; displayName = 'Repeated report policy' }) + } + Mock Invoke-IACGraphRequest -ModuleName IntuneAssignmentChecker { @{} } + Mock ConvertFrom-IACReportResponse -ModuleName IntuneAssignmentChecker { @{} } + Mock ConvertFrom-IACReportRows -ModuleName IntuneAssignmentChecker { + $row = [PSCustomObject]@{ IntuneDeviceId = 'device-1'; DeviceName = 'PC'; UPN = 'one@example.test'; PolicyStatus = 2; PspdpuLastModifiedTimeUtc = '2026-08-01T10:00:00Z' } + @($row, $row.PSObject.Copy()) + } + + $health = @(Get-IntuneAssignmentHealth -Workload DeviceConfiguration) + + @($health | Where-Object RecordType -EQ Status).Count | Should -Be 0 + ($health | Where-Object RecordType -EQ Coverage).CoverageStatus | Should -BeExactly 'Failed' + ($health | Where-Object RecordType -EQ Coverage).CoverageMessage | Should -Match 'repeated device rows' + } +} + +Describe 'v5 environment diagnostics' { + It 'uses first-page-only beta probes for every applicable workload' { + & (Get-Module IntuneAssignmentChecker) { + $script:GraphEndpoint = 'https://graph.microsoft.com' + $script:CapabilityStatus = @() + } + Mock Get-MgContext -ModuleName IntuneAssignmentChecker { + [PSCustomObject]@{ TenantId = 'tenant-1'; Environment = 'Global' } + } + Mock Invoke-IACGraphRequest -ModuleName IntuneAssignmentChecker { @{ value = @() } } + + $diagnostics = @(Test-IntuneAssignmentCheckerEnvironment) + + @($diagnostics | Where-Object Check -Like 'Graph.*' | Where-Object Check -NE GraphConnection).Count | Should -Be 4 + Should -Invoke Invoke-IACGraphRequest -ModuleName IntuneAssignmentChecker -Exactly 4 -ParameterFilter { + $Method -eq 'GET' -and $FirstPageOnly + } + } +} diff --git a/examples/fleet.config.example.json b/examples/fleet.config.example.json new file mode 100644 index 0000000..b92dfff --- /dev/null +++ b/examples/fleet.config.example.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "RulePath": null, + "WaiverPath": null, + "tenants": [ + { + "TenantId": "00000000-0000-0000-0000-000000000000", + "AppId": "00000000-0000-0000-0000-000000000000", + "CertificateThumbprint": "CERTIFICATE_THUMBPRINT", + "Environment": "Global", + "Capability": ["Full"], + "BaselinePath": null + }, + { + "TenantId": "11111111-1111-1111-1111-111111111111", + "AppId": "11111111-1111-1111-1111-111111111111", + "ClientSecretEnvironmentVariable": "IAC_CUSTOMER2_CLIENT_SECRET", + "Environment": "Global", + "Capability": ["Core", "Applications", "Devices", "Audit"] + } + ] +} diff --git a/examples/governance-waivers.example.json b/examples/governance-waivers.example.json new file mode 100644 index 0000000..a5bbaaf --- /dev/null +++ b/examples/governance-waivers.example.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "waivers": [ + { + "RuleId": "IAC001", + "PolicyId": "00000000-0000-0000-0000-000000000000", + "TargetId": null, + "Owner": "endpoint-governance@contoso.com", + "Justification": "Approved tenant-wide enrollment policy.", + "Ticket": "CHG-000000", + "ExpiresAtUtc": "2027-01-01T00:00:00Z" + } + ] +} diff --git a/packaging/Build-WindowsInstaller.ps1 b/packaging/Build-WindowsInstaller.ps1 new file mode 100644 index 0000000..23a6329 --- /dev/null +++ b/packaging/Build-WindowsInstaller.ps1 @@ -0,0 +1,80 @@ +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [Parameter()] + [string]$Version = '5.0.0', + + [Parameter()] + [string]$OutputDirectory = (Join-Path $PSScriptRoot '../artifacts'), + + [Parameter()] + [string]$GraphAuthenticationVersion = '2.38.1', + + [Parameter()] + [switch]$SkipDependencyDownload +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$moduleSource = Join-Path $repositoryRoot 'Module/IntuneAssignmentChecker' +$manifestPath = Join-Path $moduleSource 'IntuneAssignmentChecker.psd1' +$manifest = Import-PowerShellDataFile -LiteralPath $manifestPath +if ("$($manifest.ModuleVersion)" -cne $Version) { + throw "Installer version '$Version' does not match module version '$($manifest.ModuleVersion)'." +} + +# ProductCode changes deterministically with the package version while UpgradeCode +# remains stable, allowing MSI major upgrades without hand-maintained identifiers. +$algorithm = [Security.Cryptography.SHA256]::Create() +try { + $productCodeHash = $algorithm.ComputeHash( + [Text.Encoding]::UTF8.GetBytes("B9D9989C-00DE-474C-B085-3E8848CBE173|$Version") + ) +} +finally { + $algorithm.Dispose() +} +[byte[]]$productCodeBytes = @($productCodeHash[0..15]) +$productCodeBytes[7] = ($productCodeBytes[7] -band 0x0f) -bor 0x50 +$productCodeBytes[8] = ($productCodeBytes[8] -band 0x3f) -bor 0x80 +$productCode = '{' + ([guid]::new($productCodeBytes)).ToString().ToUpperInvariant() + '}' +if (-not (Get-Command wix -ErrorAction SilentlyContinue)) { + throw "WiX is required. Install the pinned tool with 'dotnet tool install --global wix --version 6.0.2'." +} + +$resolvedOutput = [IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null +$stagingRoot = Join-Path $resolvedOutput 'windows-package-staging' +if (Test-Path -LiteralPath $stagingRoot) { Remove-Item -LiteralPath $stagingRoot -Recurse -Force } +New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null + +$moduleDestination = Join-Path $stagingRoot "IntuneAssignmentChecker/$Version" +New-Item -ItemType Directory -Path $moduleDestination -Force | Out-Null +Copy-Item -Path (Join-Path $moduleSource '*') -Destination $moduleDestination -Recurse -Force + +if (-not $SkipDependencyDownload) { + Save-Module -Name Microsoft.Graph.Authentication -RequiredVersion $GraphAuthenticationVersion ` + -Repository PSGallery -Path $stagingRoot -Force -ErrorAction Stop +} +elseif (-not (Test-Path -LiteralPath (Join-Path $stagingRoot 'Microsoft.Graph.Authentication'))) { + Write-Warning 'Microsoft.Graph.Authentication was not staged because -SkipDependencyDownload was used.' +} + +$outputPath = Join-Path $resolvedOutput "IntuneAssignmentChecker-$Version-x64.msi" +& wix build (Join-Path $PSScriptRoot 'IntuneAssignmentChecker.wxs') -arch x64 ` + -d "ProductVersion=$Version" -d "ProductCode=$productCode" ` + -bindpath "ModuleSource=$stagingRoot" -output $outputPath +if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $outputPath -PathType Leaf)) { + throw "WiX failed to create '$outputPath'." +} + +$hash = (Get-FileHash -LiteralPath $outputPath -Algorithm SHA256).Hash +[PSCustomObject][ordered]@{ + Path = $outputPath + Version = $Version + Architecture = 'x64' + ProductCode = $productCode + Sha256 = $hash + GraphAuthenticationVersion = $GraphAuthenticationVersion +} diff --git a/packaging/IntuneAssignmentChecker.wxs b/packaging/IntuneAssignmentChecker.wxs new file mode 100644 index 0000000..8d159f9 --- /dev/null +++ b/packaging/IntuneAssignmentChecker.wxs @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/New-WinGetManifest.ps1 b/packaging/New-WinGetManifest.ps1 new file mode 100644 index 0000000..1898626 --- /dev/null +++ b/packaging/New-WinGetManifest.ps1 @@ -0,0 +1,103 @@ +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$InstallerPath, + + [Parameter(Mandatory)] + [uri]$InstallerUrl, + + [Parameter()] + [string]$Version = '5.0.0', + + [Parameter(Mandatory)] + [ValidatePattern('^\{[0-9A-Fa-f-]{36}\}$')] + [string]$ProductCode, + + [Parameter()] + [string]$OutputDirectory = (Join-Path $PSScriptRoot '../artifacts/winget') +) + +$ErrorActionPreference = 'Stop' +if (-not (Test-Path -LiteralPath $InstallerPath -PathType Leaf)) { throw "Installer '$InstallerPath' does not exist." } +$resolvedOutput = [IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null +$sha256 = (Get-FileHash -LiteralPath $InstallerPath -Algorithm SHA256).Hash +$schemaBase = 'https://aka.ms/winget-manifest' + +$versionManifest = @" +# Created by IntuneAssignmentChecker release automation. Do not edit the installer hash manually. +# yaml-language-server: `$schema=$schemaBase.version.1.10.0.schema.json +PackageIdentifier: UgurKoc.IntuneAssignmentChecker +PackageVersion: $Version +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.10.0 +"@ + +$installerManifest = @" +# yaml-language-server: `$schema=$schemaBase.installer.1.10.0.schema.json +PackageIdentifier: UgurKoc.IntuneAssignmentChecker +PackageVersion: $Version +InstallerType: wix +Scope: machine +InstallModes: +- silent +- silentWithProgress +UpgradeBehavior: install +ReleaseDate: $([datetime]::UtcNow.ToString('yyyy-MM-dd')) +Dependencies: + PackageDependencies: + - PackageIdentifier: Microsoft.PowerShell + MinimumVersion: 7.0.0.0 +Installers: +- Architecture: x64 + InstallerUrl: $InstallerUrl + InstallerSha256: $sha256 + ProductCode: '$ProductCode' + AppsAndFeaturesEntries: + - DisplayName: Intune Assignment Checker + Publisher: Ugur Koc + DisplayVersion: $Version + ProductCode: '$ProductCode' +ManifestType: installer +ManifestVersion: 1.10.0 +"@ + +$localeManifest = @" +# yaml-language-server: `$schema=$schemaBase.defaultLocale.1.10.0.schema.json +PackageIdentifier: UgurKoc.IntuneAssignmentChecker +PackageVersion: $Version +PackageLocale: en-US +Publisher: Ugur Koc +PublisherUrl: https://github.com/ugurkocde +PublisherSupportUrl: https://github.com/ugurkocde/IntuneAssignmentChecker/issues +Author: Ugur Koc +PackageName: Intune Assignment Checker +PackageUrl: https://github.com/ugurkocde/IntuneAssignmentChecker +License: MIT +LicenseUrl: https://github.com/ugurkocde/IntuneAssignmentChecker/blob/v$Version/LICENSE +ShortDescription: Audit, simulate, and govern Microsoft Intune assignments from PowerShell or its terminal UI. +Description: A PowerShell-native, read-only assignment governance platform for Microsoft Intune with a full-parity terminal UI, snapshots, drift analysis, change simulation, delivery health, and multi-tenant scans. +Moniker: intune-assignment-checker +Tags: +- intune +- microsoft-graph +- powershell +- security +- terminal-ui +ReleaseNotesUrl: https://github.com/ugurkocde/IntuneAssignmentChecker/releases/tag/v$Version +ManifestType: defaultLocale +ManifestVersion: 1.10.0 +"@ + +$files = [ordered]@{ + 'UgurKoc.IntuneAssignmentChecker.yaml' = $versionManifest + 'UgurKoc.IntuneAssignmentChecker.installer.yaml' = $installerManifest + 'UgurKoc.IntuneAssignmentChecker.locale.en-US.yaml' = $localeManifest +} +foreach ($entry in $files.GetEnumerator()) { + [IO.File]::WriteAllText((Join-Path $resolvedOutput $entry.Key), $entry.Value.Trim() + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) +} +Get-ChildItem -LiteralPath $resolvedOutput -File | Sort-Object Name diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..44d8510 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,18 @@ +# Windows and WinGet packaging + +Version 5 remains a PowerShell module. The Windows artifact is an MSI that copies +the exact module source plus the pinned `Microsoft.Graph.Authentication` runtime +dependency into `C:\Program Files\PowerShell\Modules`. It does not compile or wrap +the module as an executable. + +Build on Windows with PowerShell 7, the .NET SDK, and WiX 6.0.2: + +```powershell +dotnet tool install --global wix --version 6.0.2 +./packaging/Build-WindowsInstaller.ps1 +``` + +The release workflow signs the MSI, emits an SBOM and provenance attestation, +and generates versioned WinGet manifests after the signed artifact hash is known. +The generated manifest directory is ready for `winget validate` and submission to +`microsoft/winget-pkgs`. From 891b7749ad7d1eeabb41f3de6d1199bfc3e36105 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:21:47 +0200 Subject: [PATCH 02/13] fix: pin valid artifact upload action --- .github/workflows/windows-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 53d4feb..098a4bc 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -111,7 +111,7 @@ jobs: upload-artifact: false - name: Upload build artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0b # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: intune-assignment-checker-windows-${{ steps.package.outputs.version }} path: | From 2ac83d95dbf57a0283db210c8e0f7abd414a5c76 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:26:07 +0200 Subject: [PATCH 03/13] fix: repair cross-platform tests and MSI build --- .github/workflows/pester.yml | 10 +++++++++- packaging/Build-WindowsInstaller.ps1 | 11 +++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml index a93b300..480738d 100644 --- a/.github/workflows/pester.yml +++ b/.github/workflows/pester.yml @@ -32,15 +32,23 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install Pester 5 + - name: Install test dependencies shell: pwsh run: | + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted $existing = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -ge 5 } | Select-Object -First 1 if (-not $existing) { Install-Module Pester -MinimumVersion 5.0.0 -Scope CurrentUser -Force -SkipPublisherCheck } + if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { + Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force + } Import-Module Pester -MinimumVersion 5.0.0 Get-Module Pester | Format-List Name, Version + Get-Module -ListAvailable Microsoft.Graph.Authentication | + Sort-Object Version -Descending | + Select-Object -First 1 Name, Version | + Format-List - name: Run unit tests shell: pwsh diff --git a/packaging/Build-WindowsInstaller.ps1 b/packaging/Build-WindowsInstaller.ps1 index 23a6329..a6afc2d 100644 --- a/packaging/Build-WindowsInstaller.ps1 +++ b/packaging/Build-WindowsInstaller.ps1 @@ -62,11 +62,14 @@ elseif (-not (Test-Path -LiteralPath (Join-Path $stagingRoot 'Microsoft.Graph.Au } $outputPath = Join-Path $resolvedOutput "IntuneAssignmentChecker-$Version-x64.msi" -& wix build (Join-Path $PSScriptRoot 'IntuneAssignmentChecker.wxs') -arch x64 ` +$wixOutput = @(& wix build (Join-Path $PSScriptRoot 'IntuneAssignmentChecker.wxs') -arch x64 ` -d "ProductVersion=$Version" -d "ProductCode=$productCode" ` - -bindpath "ModuleSource=$stagingRoot" -output $outputPath -if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $outputPath -PathType Leaf)) { - throw "WiX failed to create '$outputPath'." + -bindpath "ModuleSource=$stagingRoot" -o $outputPath 2>&1) +$wixExitCode = $LASTEXITCODE +$wixOutput | ForEach-Object { Write-Host $_ } +if ($wixExitCode -ne 0 -or -not (Test-Path -LiteralPath $outputPath -PathType Leaf)) { + $diagnostics = if ($wixOutput.Count -gt 0) { $wixOutput -join [Environment]::NewLine } else { 'No diagnostic output was returned.' } + throw "WiX failed to create '$outputPath' (exit code $wixExitCode).$([Environment]::NewLine)$diagnostics" } $hash = (Get-FileHash -LiteralPath $outputPath -Algorithm SHA256).Hash From ea2e7e1308883680fc5bfd6eea396d2e82efc642 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:28:45 +0200 Subject: [PATCH 04/13] ci: diagnose and verify MSI lifecycle --- .github/workflows/windows-package.yml | 51 +++++++++++++++++++++------ 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 098a4bc..d7c3dd0 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -80,18 +80,47 @@ jobs: - name: Verify installation and removal shell: pwsh run: | + $ErrorActionPreference = 'Stop' $msi = '${{ steps.package.outputs.installer }}' - $install = Start-Process msiexec.exe -ArgumentList @('/i', $msi, '/qn', '/norestart') -Wait -PassThru - if ($install.ExitCode -ne 0) { throw "MSI installation failed with exit code $($install.ExitCode)." } - Import-Module IntuneAssignmentChecker -RequiredVersion '${{ steps.package.outputs.version }}' -Force - $module = Get-Module IntuneAssignmentChecker - if ($module.Version.ToString() -cne '${{ steps.package.outputs.version }}') { throw 'Installed module version mismatch.' } - $catalog = @(Get-IntuneAssignmentOperation) - $expected = @($module.ExportedFunctions.Keys | Where-Object { $_ -notin @('Get-IntuneAssignmentOperation', 'Invoke-IntuneAssignmentChecker', 'Start-IntuneAssignmentCheckerTui') }) - if (@(Compare-Object $expected $catalog.Name).Count -gt 0) { throw 'TUI operation catalog is not in parity with installed exports.' } - Remove-Module IntuneAssignmentChecker - $remove = Start-Process msiexec.exe -ArgumentList @('/x', '${{ steps.package.outputs.product_code }}', '/qn', '/norestart') -Wait -PassThru - if ($remove.ExitCode -ne 0) { throw "MSI removal failed with exit code $($remove.ExitCode)." } + $installLog = Join-Path $env:RUNNER_TEMP 'intune-assignment-checker-install.log' + $removeLog = Join-Path $env:RUNNER_TEMP 'intune-assignment-checker-remove.log' + $installed = $false + try { + Write-Host "Installing $msi" + & msiexec.exe /i $msi /qn /norestart /L*V $installLog + $installExitCode = $LASTEXITCODE + Write-Host "MSI install exit code: $installExitCode" + if ($installExitCode -ne 0) { + Get-Content -LiteralPath $installLog -Tail 200 -ErrorAction SilentlyContinue + throw "MSI installation failed with exit code $installExitCode." + } + $installed = $true + + $available = @(Get-Module -ListAvailable IntuneAssignmentChecker | + Where-Object Version -EQ '${{ steps.package.outputs.version }}') + $available | Format-List Name, Version, Path + if ($available.Count -eq 0) { throw 'The installed module was not discoverable through PSModulePath.' } + + Import-Module IntuneAssignmentChecker -RequiredVersion '${{ steps.package.outputs.version }}' -Force + $module = Get-Module IntuneAssignmentChecker | Select-Object -First 1 + if ($module.Version.ToString() -cne '${{ steps.package.outputs.version }}') { throw 'Installed module version mismatch.' } + $catalog = @(Get-IntuneAssignmentOperation) + $expected = @($module.ExportedFunctions.Keys | Where-Object { $_ -notin @('Get-IntuneAssignmentOperation', 'Invoke-IntuneAssignmentChecker', 'Start-IntuneAssignmentCheckerTui') }) + if (@(Compare-Object $expected $catalog.Name).Count -gt 0) { throw 'TUI operation catalog is not in parity with installed exports.' } + Write-Host "Verified $($catalog.Count) installed TUI operations." + } + finally { + Remove-Module IntuneAssignmentChecker -ErrorAction SilentlyContinue + if ($installed) { + & msiexec.exe /x '${{ steps.package.outputs.product_code }}' /qn /norestart /L*V $removeLog + $removeExitCode = $LASTEXITCODE + Write-Host "MSI removal exit code: $removeExitCode" + if ($removeExitCode -ne 0) { + Get-Content -LiteralPath $removeLog -Tail 200 -ErrorAction SilentlyContinue + throw "MSI removal failed with exit code $removeExitCode." + } + } + } - name: Generate WinGet manifests shell: pwsh From 10bdfc4754d9c1e17f1797c92ef7f274f6b3ae5f Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:30:17 +0200 Subject: [PATCH 05/13] ci: wait for Windows Installer exit codes --- .github/workflows/windows-package.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index d7c3dd0..67a3c14 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -87,8 +87,10 @@ jobs: $installed = $false try { Write-Host "Installing $msi" - & msiexec.exe /i $msi /qn /norestart /L*V $installLog - $installExitCode = $LASTEXITCODE + $install = Start-Process msiexec.exe -ArgumentList @( + '/i', $msi, '/qn', '/norestart', '/L*V', $installLog + ) -Wait -PassThru + $installExitCode = $install.ExitCode Write-Host "MSI install exit code: $installExitCode" if ($installExitCode -ne 0) { Get-Content -LiteralPath $installLog -Tail 200 -ErrorAction SilentlyContinue @@ -112,8 +114,10 @@ jobs: finally { Remove-Module IntuneAssignmentChecker -ErrorAction SilentlyContinue if ($installed) { - & msiexec.exe /x '${{ steps.package.outputs.product_code }}' /qn /norestart /L*V $removeLog - $removeExitCode = $LASTEXITCODE + $remove = Start-Process msiexec.exe -ArgumentList @( + '/x', '${{ steps.package.outputs.product_code }}', '/qn', '/norestart', '/L*V', $removeLog + ) -Wait -PassThru + $removeExitCode = $remove.ExitCode Write-Host "MSI removal exit code: $removeExitCode" if ($removeExitCode -ne 0) { Get-Content -LiteralPath $removeLog -Tail 200 -ErrorAction SilentlyContinue From 615163b9e3bf9b56caaf5c2ce98a87b8de00652f Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:32:41 +0200 Subject: [PATCH 06/13] ci: release imported modules before MSI removal --- .github/workflows/windows-package.yml | 31 ++++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 67a3c14..42428b5 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -84,6 +84,7 @@ jobs: $msi = '${{ steps.package.outputs.installer }}' $installLog = Join-Path $env:RUNNER_TEMP 'intune-assignment-checker-install.log' $removeLog = Join-Path $env:RUNNER_TEMP 'intune-assignment-checker-remove.log' + $verificationScript = Join-Path $env:RUNNER_TEMP 'verify-intune-assignment-checker.ps1' $installed = $false try { Write-Host "Installing $msi" @@ -103,16 +104,30 @@ jobs: $available | Format-List Name, Version, Path if ($available.Count -eq 0) { throw 'The installed module was not discoverable through PSModulePath.' } - Import-Module IntuneAssignmentChecker -RequiredVersion '${{ steps.package.outputs.version }}' -Force - $module = Get-Module IntuneAssignmentChecker | Select-Object -First 1 - if ($module.Version.ToString() -cne '${{ steps.package.outputs.version }}') { throw 'Installed module version mismatch.' } - $catalog = @(Get-IntuneAssignmentOperation) - $expected = @($module.ExportedFunctions.Keys | Where-Object { $_ -notin @('Get-IntuneAssignmentOperation', 'Invoke-IntuneAssignmentChecker', 'Start-IntuneAssignmentCheckerTui') }) - if (@(Compare-Object $expected $catalog.Name).Count -gt 0) { throw 'TUI operation catalog is not in parity with installed exports.' } - Write-Host "Verified $($catalog.Count) installed TUI operations." + @' + param([Parameter(Mandatory)][string]$Version) + $ErrorActionPreference = 'Stop' + Import-Module IntuneAssignmentChecker -RequiredVersion $Version -Force + $module = Get-Module IntuneAssignmentChecker | Select-Object -First 1 + if ($module.Version.ToString() -cne $Version) { throw 'Installed module version mismatch.' } + $catalog = @(Get-IntuneAssignmentOperation) + $expected = @($module.ExportedFunctions.Keys | Where-Object { + $_ -notin @('Get-IntuneAssignmentOperation', 'Invoke-IntuneAssignmentChecker', 'Start-IntuneAssignmentCheckerTui') + }) + if (@(Compare-Object $expected $catalog.Name).Count -gt 0) { + throw 'TUI operation catalog is not in parity with installed exports.' + } + Write-Host "Verified $($catalog.Count) installed TUI operations." + '@ | Set-Content -LiteralPath $verificationScript -Encoding utf8 + & (Join-Path $PSHOME 'pwsh.exe') -NoLogo -NoProfile -File $verificationScript ` + -Version '${{ steps.package.outputs.version }}' + $verificationExitCode = $LASTEXITCODE + if ($verificationExitCode -ne 0) { + throw "Installed-module verification failed with exit code $verificationExitCode." + } } finally { - Remove-Module IntuneAssignmentChecker -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $verificationScript -Force -ErrorAction SilentlyContinue if ($installed) { $remove = Start-Process msiexec.exe -ArgumentList @( '/x', '${{ steps.package.outputs.product_code }}', '/qn', '/norestart', '/L*V', $removeLog From d2a2d30ed4ccebc3434cd428677c158803245be1 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:53:24 +0200 Subject: [PATCH 07/13] feat: build native task-oriented TUI --- .github/workflows/windows-package.yml | 21 +- .../IntuneAssignmentChecker.psd1 | 3 +- .../Private/TerminalUI.ps1 | 226 ---- .../Private/TuiFeatureRegistry.ps1 | 221 +++ .../Private/TuiInput.ps1 | 314 +++++ .../Private/TuiRenderer.ps1 | 494 +++++++ .../Private/TuiWorkflows.ps1 | 1163 ++++++++++++++++ .../Public/Compare-IntuneGroupAssignment.ps1 | 6 +- .../Public/Get-IntuneAssignmentOperation.ps1 | 6 +- .../Public/Get-IntuneEmptyGroup.ps1 | 8 +- .../Public/Get-IntuneFailedAssignment.ps1 | 8 +- .../Public/Get-IntuneUserDeviceAssignment.ps1 | 8 +- .../Public/Invoke-IntuneAssignmentChecker.ps1 | 50 +- .../Public/Search-IntuneSetting.ps1 | 8 +- .../Start-IntuneAssignmentCheckerTui.ps1 | 117 +- .../Public/Test-IntuneGroupMembership.ps1 | 6 +- .../Public/Test-IntuneGroupRemoval.ps1 | 8 +- README.md | 56 +- Tests/Unit/V5Platform.Tests.ps1 | 442 +++++- .../IntuneAssignmentChecker-Tui-Concepts.html | 1196 +++++++++++++++++ packaging/New-WinGetManifest.ps1 | 4 +- 21 files changed, 3976 insertions(+), 389 deletions(-) delete mode 100644 Module/IntuneAssignmentChecker/Private/TerminalUI.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/TuiFeatureRegistry.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/TuiInput.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/TuiRenderer.ps1 create mode 100644 Module/IntuneAssignmentChecker/Private/TuiWorkflows.ps1 create mode 100644 examples/IntuneAssignmentChecker-Tui-Concepts.html diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 42428b5..2bb42ec 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -110,14 +110,27 @@ jobs: Import-Module IntuneAssignmentChecker -RequiredVersion $Version -Force $module = Get-Module IntuneAssignmentChecker | Select-Object -First 1 if ($module.Version.ToString() -cne $Version) { throw 'Installed module version mismatch.' } - $catalog = @(Get-IntuneAssignmentOperation) $expected = @($module.ExportedFunctions.Keys | Where-Object { $_ -notin @('Get-IntuneAssignmentOperation', 'Invoke-IntuneAssignmentChecker', 'Start-IntuneAssignmentCheckerTui') }) - if (@(Compare-Object $expected $catalog.Name).Count -gt 0) { - throw 'TUI operation catalog is not in parity with installed exports.' + $verification = & $module { + param([string[]]$Expected) + $parity = Test-IACTuiFeatureParity -CommandName $Expected + $state = New-IACTuiState + $frame = Get-IACTuiFrame -State $state -Width 120 -Height 36 + [PSCustomObject]@{ + Parity = $parity + Frame = $frame + HitTargetCount = $state.HitTargets.Count + } + } $expected + if (-not $verification.Parity.Complete) { + throw "TUI workflow registry is not in parity with installed exports. Missing: $($verification.Parity.Missing -join ', '); unknown: $($verification.Parity.Unknown -join ', ')." + } + if ($verification.Frame -notmatch 'INTUNE ASSIGNMENT CHECKER' -or $verification.HitTargetCount -lt 12) { + throw 'The installed terminal command center did not render its navigation and mouse targets.' } - Write-Host "Verified $($catalog.Count) installed TUI operations." + Write-Host "Verified $($verification.Parity.Mapped.Count) installed TUI workflow mappings and $($verification.HitTargetCount) mouse targets." '@ | Set-Content -LiteralPath $verificationScript -Encoding utf8 & (Join-Path $PSHOME 'pwsh.exe') -NoLogo -NoProfile -File $verificationScript ` -Version '${{ steps.package.outputs.version }}' diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 index 5fe06bb..57c021d 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 @@ -68,7 +68,8 @@ IconUri = '' ReleaseNotes = @' Version 5.0.0: -- Add a PowerShell-native terminal UI whose dynamic operation catalog stays in parity with exported module commands. +- Add a mouse- and keyboard-enabled PowerShell terminal command center with native workspaces for every exported module capability. +- Add structured -PassThru results to the remaining legacy assignment, simulation, failure, comparison, empty-group, and setting-search commands. - Add assignment governance, change simulation, drift attribution, fleet orchestration, delivery health, RBAC analysis, filter-set governance, capability-based authentication, and environment diagnostics. - Add schema-governed structured output, MSI packaging, and WinGet release automation without converting the module to an executable. diff --git a/Module/IntuneAssignmentChecker/Private/TerminalUI.ps1 b/Module/IntuneAssignmentChecker/Private/TerminalUI.ps1 deleted file mode 100644 index 4601bfa..0000000 --- a/Module/IntuneAssignmentChecker/Private/TerminalUI.ps1 +++ /dev/null @@ -1,226 +0,0 @@ -function Test-IACVirtualTerminal { - [CmdletBinding()] - param() - - if ($env:NO_COLOR) { return $false } - try { return [bool]$Host.UI.SupportsVirtualTerminal } - catch { return $false } -} - -function Write-IACTuiText { - [CmdletBinding()] - param( - [Parameter(Mandatory)][AllowEmptyString()][string]$Text, - [ValidateSet('Default', 'Accent', 'Selected', 'Muted', 'Success', 'Warning', 'Error')] - [string]$Style = 'Default', - [switch]$NoNewline - ) - - $colors = @{ - Default = "`e[0m" - Accent = "`e[1;36m" - Selected = "`e[30;46m" - Muted = "`e[90m" - Success = "`e[32m" - Warning = "`e[33m" - Error = "`e[31m" - } - if (Test-IACVirtualTerminal) { - Write-Host "$($colors[$Style])$Text`e[0m" -NoNewline:$NoNewline - } - else { - $consoleColor = switch ($Style) { - 'Accent' { 'Cyan' } - 'Selected' { 'Black' } - 'Muted' { 'DarkGray' } - 'Success' { 'Green' } - 'Warning' { 'Yellow' } - 'Error' { 'Red' } - default { 'Gray' } - } - if ($Style -eq 'Selected') { - Write-Host $Text -ForegroundColor Black -BackgroundColor Cyan -NoNewline:$NoNewline - } - else { - Write-Host $Text -ForegroundColor $consoleColor -NoNewline:$NoNewline - } - } -} - -function Read-IACTuiParameterValue { - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Parameter, - [Parameter(Mandatory)][string]$CommandName - ) - - $required = if ($Parameter.Mandatory) { 'required' } else { 'optional; Enter skips' } - $choices = if (@($Parameter.ValidateSet).Count -gt 0) { - " Choices: $(@($Parameter.ValidateSet) -join ', ')." - } - else { '' } - Write-IACTuiText -Text "`n-$($Parameter.Name)" -Style Accent - Write-IACTuiText -Text " $($Parameter.TypeName); $required.$choices" -Style Muted - if ($Parameter.HelpMessage) { Write-IACTuiText -Text " $($Parameter.HelpMessage)" -Style Muted } - - if ($Parameter.IsSwitch) { - $answer = Read-Host ' Enable? (y/N)' - return [PSCustomObject]@{ Supplied = $answer -match '^(?i:y|yes)$'; Value = $true } - } - if ($Parameter.TypeName -eq 'SecureString') { - $answer = Read-Host ' Supply this secret? (y/N)' - if ($answer -notmatch '^(?i:y|yes)$') { - if ($Parameter.Mandatory) { throw "-$($Parameter.Name) is required for $CommandName." } - return [PSCustomObject]@{ Supplied = $false; Value = $null } - } - return [PSCustomObject]@{ Supplied = $true; Value = (Read-Host ' Secret' -AsSecureString) } - } - if ($Parameter.TypeName -eq 'PSCredential') { - $answer = Read-Host ' Supply a credential? (y/N)' - if ($answer -notmatch '^(?i:y|yes)$') { - if ($Parameter.Mandatory) { throw "-$($Parameter.Name) is required for $CommandName." } - return [PSCustomObject]@{ Supplied = $false; Value = $null } - } - return [PSCustomObject]@{ Supplied = $true; Value = (Get-Credential -Message "$CommandName -$($Parameter.Name)") } - } - - while ($true) { - $value = Read-Host ' Value' - if ([string]::IsNullOrWhiteSpace($value)) { - if ($Parameter.Mandatory) { - Write-IACTuiText -Text " A value is required." -Style Warning - continue - } - return [PSCustomObject]@{ Supplied = $false; Value = $null } - } - if ($Parameter.IsArray) { - if ($value.StartsWith('@') -and (Test-Path -LiteralPath $value.Substring(1) -PathType Leaf)) { - try { - $items = @(Get-Content -LiteralPath $value.Substring(1) -Raw -ErrorAction Stop | - ConvertFrom-Json -Depth 30 -ErrorAction Stop) - } - catch { - Write-IACTuiText -Text " Could not read JSON input: $($_.Exception.Message)" -Style Warning - continue - } - } - else { $items = @($value -split ',' | ForEach-Object Trim | Where-Object { $_ }) } - $invalidItems = if (@($Parameter.ValidateSet).Count -gt 0) { - @($items | Where-Object { $_ -notin $Parameter.ValidateSet }) - } - else { @() } - if ($invalidItems.Count -gt 0) { - Write-IACTuiText -Text " Invalid value(s): $($invalidItems -join ', '). Choose from: $(@($Parameter.ValidateSet) -join ', ')." -Style Warning - continue - } - try { - $targetType = [System.Management.Automation.PSTypeName]::new("$($Parameter.Type)").Type - $convertedItems = if ($targetType) { - [System.Management.Automation.LanguagePrimitives]::ConvertTo($items, $targetType) - } - else { $items } - return [PSCustomObject]@{ Supplied = $true; Value = $convertedItems } - } - catch { - Write-IACTuiText -Text " Could not convert the value: $($_.Exception.Message)" -Style Warning - continue - } - } - if (@($Parameter.ValidateSet).Count -gt 0 -and $value -notin $Parameter.ValidateSet) { - Write-IACTuiText -Text " Choose one of: $(@($Parameter.ValidateSet) -join ', ')." -Style Warning - continue - } - try { - $targetType = [System.Management.Automation.PSTypeName]::new("$($Parameter.Type)").Type - $convertedValue = if ($targetType) { - [System.Management.Automation.LanguagePrimitives]::ConvertTo($value, $targetType) - } - else { $value } - return [PSCustomObject]@{ Supplied = $true; Value = $convertedValue } - } - catch { - Write-IACTuiText -Text " Could not convert the value: $($_.Exception.Message)" -Style Warning - } - } -} - -function Read-IACTuiOperationParameters { - [CmdletBinding()] - param([Parameter(Mandatory)]$Operation) - - $sets = @($Operation.ParameterSets) - $parameterSet = $sets | Where-Object IsDefault | Select-Object -First 1 - if (-not $parameterSet) { $parameterSet = $sets | Select-Object -First 1 } - if ($sets.Count -gt 1) { - Write-IACTuiText -Text "`nParameter sets" -Style Accent - for ($index = 0; $index -lt $sets.Count; $index++) { - $suffix = if ($sets[$index].IsDefault) { ' (default)' } else { '' } - Write-Host " $($index + 1). $($sets[$index].Name)$suffix" - } - $selection = Read-Host "Select [1-$($sets.Count)] or Enter for default" - if ($selection -match '^\d+$' -and [int]$selection -ge 1 -and [int]$selection -le $sets.Count) { - $parameterSet = $sets[[int]$selection - 1] - } - } - - $values = @{} - foreach ($parameter in @($parameterSet.Parameters)) { - $result = Read-IACTuiParameterValue -Parameter $parameter -CommandName $Operation.Name - if ($result.Supplied) { $values[$parameter.Name] = $result.Value } - } - return $values -} - -function Show-IACTuiOperation { - [CmdletBinding()] - param([Parameter(Mandatory)]$Operation) - - try { - Clear-Host - Write-IACTuiText -Text $Operation.Name -Style Accent - Write-IACTuiText -Text $Operation.Synopsis -Style Muted - $parameters = Read-IACTuiOperationParameters -Operation $Operation - Write-IACTuiText -Text "`nRunning $($Operation.Name)...`n" -Style Success - & $Operation.Name @parameters | Out-Host - } - catch { - Write-IACTuiText -Text "`n$($_.Exception.Message)" -Style Error - } - Write-IACTuiText -Text "`nPress any key to return to the operation list." -Style Muted -NoNewline - $null = [Console]::ReadKey($true) -} - -function Show-IACTuiScreen { - [CmdletBinding()] - param( - [Parameter(Mandatory)][object[]]$Operations, - [Parameter(Mandatory)][int]$SelectedIndex, - [AllowEmptyString()][string]$Filter = '' - ) - - Clear-Host - Write-IACTuiText -Text 'INTUNE ASSIGNMENT CHECKER 5.0' -Style Accent - $tenant = if ($script:CurrentTenantName) { $script:CurrentTenantName } elseif ($script:CurrentTenantId) { $script:CurrentTenantId } else { 'Not connected' } - Write-IACTuiText -Text "Tenant: $tenant | Filter: $(if ($Filter) { $Filter } else { '(none)' })" -Style Muted - Write-Host '' - - $height = try { [Console]::WindowHeight } catch { 30 } - $pageSize = [math]::Max(8, $height - 12) - $pageStart = [math]::Floor($SelectedIndex / $pageSize) * $pageSize - $pageEnd = [math]::Min($Operations.Count - 1, $pageStart + $pageSize - 1) - for ($index = $pageStart; $index -le $pageEnd; $index++) { - $operation = $Operations[$index] - $line = ' {0,-18} {1}' -f "[$($operation.Category)]", $operation.Name - if ($index -eq $SelectedIndex) { Write-IACTuiText -Text "> $line" -Style Selected } - else { Write-Host " $line" } - } - - if ($Operations.Count -gt 0) { - $selected = $Operations[$SelectedIndex] - Write-Host '' - Write-IACTuiText -Text $selected.Synopsis -Style Muted - Write-IACTuiText -Text "Capabilities: $(@($selected.Capabilities) -join ', ')" -Style Muted - } - Write-Host '' - Write-IACTuiText -Text '↑/↓ J/K navigate PgUp/PgDn jump Enter run / filter C/T switch tenant ? help Q quit' -Style Accent -} diff --git a/Module/IntuneAssignmentChecker/Private/TuiFeatureRegistry.ps1 b/Module/IntuneAssignmentChecker/Private/TuiFeatureRegistry.ps1 new file mode 100644 index 0000000..26ab346 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/TuiFeatureRegistry.ps1 @@ -0,0 +1,221 @@ +function New-IACTuiActionDefinition { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Id, + [Parameter(Mandatory)][string]$Label, + [Parameter(Mandatory)][char]$Key, + [Parameter(Mandatory)][string]$Description, + [string[]]$Commands = @(), + [switch]$Primary + ) + + [PSCustomObject][ordered]@{ + Id = $Id + Label = $Label + Key = [char]::ToUpperInvariant($Key) + Description = $Description + Commands = @($Commands) + Primary = [bool]$Primary + } +} + +function New-IACTuiFeatureDefinition { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Id, + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Summary, + [Parameter(Mandatory)][string[]]$Capabilities, + [Parameter(Mandatory)][object[]]$Actions, + [string[]]$Commands = @() + ) + + $mappedCommands = @($Commands) + @($Actions | ForEach-Object Commands) + [PSCustomObject][ordered]@{ + Id = $Id + Title = $Title + Summary = $Summary + Capabilities = @($Capabilities) + Actions = @($Actions) + Commands = @($mappedCommands | Where-Object { $_ } | Select-Object -Unique) + } +} + +function Get-IACTuiFeatureRegistry { + [CmdletBinding()] + param() + + @( + New-IACTuiFeatureDefinition -Id Overview -Title 'Overview' -Capabilities Core ` + -Summary 'Tenant posture, coverage, priority findings, drift, and delivery health.' ` + -Actions @( + New-IACTuiActionDefinition -Id RefreshOverview -Label 'Refresh posture' -Key R -Primary ` + -Description 'Refresh the connected tenant posture from shared governance and coverage data.' ` + -Commands @('Test-IntuneAssignmentGovernance') + New-IACTuiActionDefinition -Id ResumeScan -Label 'Resume scan' -Key C ` + -Description 'Continue a checkpointed assignment scan within a time budget.' ` + -Commands @('Invoke-IntuneAssignmentScan') + ) + + New-IACTuiFeatureDefinition -Id Assignments -Title 'Assignments' -Capabilities @('Core', 'Applications', 'Devices') ` + -Summary 'Search policies and inspect targeting for users, groups, and devices.' ` + -Actions @( + New-IACTuiActionDefinition -Id SearchAssignments -Label 'Search policies' -Key R -Primary ` + -Description 'Find policies and applications by name and inspect their targets.' ` + -Commands @('Search-IntunePolicy') + New-IACTuiActionDefinition -Id FindUserAssignments -Label 'Find user' -Key U ` + -Description 'Resolve assignments applying to one or more users.' ` + -Commands @('Get-IntuneUserAssignment') + New-IACTuiActionDefinition -Id FindDeviceAssignments -Label 'Find device' -Key D ` + -Description 'Resolve assignments applying to one or more devices.' ` + -Commands @('Get-IntuneDeviceAssignment') + New-IACTuiActionDefinition -Id FindGroupAssignments -Label 'Find group' -Key G ` + -Description 'Inspect assignments that include or exclude selected groups.' ` + -Commands @('Get-IntuneGroupAssignment') + New-IACTuiActionDefinition -Id ExplainEffectiveAssignment -Label 'Explain effective state' -Key E ` + -Description 'Trace effective targeting for a user, a device, or both.' ` + -Commands @('Get-IntuneEffectiveAssignment', 'Get-IntuneUserDeviceAssignment') + New-IACTuiActionDefinition -Id BrowseAssignmentInventory -Label 'Browse inventory' -Key I ` + -Description 'Review all policies, broad assignments, gaps, and empty target groups.' ` + -Commands @('Get-IntuneAllPolicies', 'Get-IntuneAllUsersAssignment', 'Get-IntuneAllDevicesAssignment', 'Get-IntuneUnassignedPolicy', 'Get-IntuneEmptyGroup') + New-IACTuiActionDefinition -Id CompareGroupTargeting -Label 'Compare groups' -Key C ` + -Description 'Compare targeting patterns across selected groups.' ` + -Commands @('Compare-IntuneGroupAssignment') + New-IACTuiActionDefinition -Id SearchConfiguredSettings -Label 'Search settings' -Key S ` + -Description 'Search configured setting values across supported policies.' ` + -Commands @('Search-IntuneSetting') + ) + + New-IACTuiFeatureDefinition -Id Governance -Title 'Governance' -Capabilities @('Core', 'Audit') ` + -Summary 'Prioritize assignment risk with evidence, remediation, and approved exceptions.' ` + -Actions @( + New-IACTuiActionDefinition -Id RefreshGovernance -Label 'Run governance scan' -Key R -Primary ` + -Description 'Evaluate the connected tenant or a saved snapshot against governance rules.' ` + -Commands @('Test-IntuneAssignmentGovernance') + ) + + New-IACTuiFeatureDefinition -Id Simulator -Title 'Change simulator' -Capabilities @('Core', 'Devices') ` + -Summary 'Model proposed targeting changes and prove their blast radius without Graph writes.' ` + -Actions @( + New-IACTuiActionDefinition -Id SimulateAssignmentChange -Label 'Propose assignment change' -Key R -Primary ` + -Description 'Compare before and after states for a proposed assignment mutation.' ` + -Commands @('Test-IntuneAssignmentChange') + New-IACTuiActionDefinition -Id SimulateMembershipAdd -Label 'Add group membership' -Key A ` + -Description 'Show assignments gained when a user or device joins a group.' ` + -Commands @('Test-IntuneGroupMembership') + New-IACTuiActionDefinition -Id SimulateMembershipRemoval -Label 'Remove group membership' -Key D ` + -Description 'Show assignments lost when a user or device leaves a group.' ` + -Commands @('Test-IntuneGroupRemoval') + ) + + New-IACTuiFeatureDefinition -Id Drift -Title 'Drift' -Capabilities @('Core', 'Audit') ` + -Summary 'Manage approved baselines and investigate who changed assignment state.' ` + -Actions @( + New-IACTuiActionDefinition -Id RefreshDrift -Label 'Compare baseline' -Key R -Primary ` + -Description 'Capture or load current state and compare it with an approved baseline.' ` + -Commands @('Get-IntuneAssignmentDrift') + New-IACTuiActionDefinition -Id ApproveBaseline -Label 'Approve baseline' -Key A ` + -Description 'Promote a reviewed snapshot to the approved drift baseline.' ` + -Commands @('Get-IntuneAssignmentDrift') + New-IACTuiActionDefinition -Id CaptureSnapshot -Label 'Capture snapshot' -Key C ` + -Description 'Save deterministic assignment state for later review.' ` + -Commands @('Invoke-IntuneAssignmentScan', 'Export-IntuneAssignmentSnapshot') + New-IACTuiActionDefinition -Id CompareSnapshots -Label 'Compare snapshots' -Key F ` + -Description 'Compare two saved assignment snapshots.' ` + -Commands @('Compare-IntuneAssignmentSnapshot') + ) + + New-IACTuiFeatureDefinition -Id Health -Title 'Delivery health' -Capabilities @('Applications', 'Devices') ` + -Summary 'Separate assignment targeting from deployment success, failure, conflict, and staleness.' ` + -Actions @( + New-IACTuiActionDefinition -Id RefreshHealth -Label 'Load delivery health' -Key R -Primary ` + -Description 'Correlate targeting with policy and application delivery status.' ` + -Commands @('Get-IntuneAssignmentHealth') + New-IACTuiActionDefinition -Id LoadFailures -Label 'Show failures' -Key F ` + -Description 'Review failed policy and application deployments.' ` + -Commands @('Get-IntuneFailedAssignment') + ) + + New-IACTuiFeatureDefinition -Id Access -Title 'RBAC & scope' -Capabilities ScopeTags ` + -Summary 'Trace administrators through roles, scopes, scope tags, and manageable policies.' ` + -Actions @( + New-IACTuiActionDefinition -Id RefreshAccess -Label 'Analyze access' -Key R -Primary ` + -Description 'Explain administrative access to assignment records.' ` + -Commands @('Get-IntuneAssignmentAccess') + ) + + New-IACTuiFeatureDefinition -Id Filters -Title 'Filters' -Capabilities Devices ` + -Summary 'Audit filter inventory, references, compatibility, and real-device evaluations.' ` + -Actions @( + New-IACTuiActionDefinition -Id RefreshFilters -Label 'Audit filter set' -Key R -Primary ` + -Description 'Find unused, duplicate, invalid, overly broad, and ineffective filters.' ` + -Commands @('Test-IntuneAssignmentFilterSet') + New-IACTuiActionDefinition -Id EvaluateFilter -Label 'Evaluate device' -Key E ` + -Description 'Test an assignment filter safely against a selected managed device.' ` + -Commands @('Test-IntuneAssignmentFilter') + ) + + New-IACTuiFeatureDefinition -Id Fleet -Title 'Fleet' -Capabilities Core ` + -Summary 'Run isolated tenant scans and compare governance posture across the fleet.' ` + -Actions @( + New-IACTuiActionDefinition -Id RunFleetScan -Label 'Scan tenant fleet' -Key R -Primary ` + -Description 'Scan configured tenants independently and consolidate their findings.' ` + -Commands @('Invoke-IntuneAssignmentFleetScan') + ) + + New-IACTuiFeatureDefinition -Id Reports -Title 'Reports & data' -Capabilities Core ` + -Summary 'Create human-readable reports and migrate saved assignment records.' ` + -Actions @( + New-IACTuiActionDefinition -Id CreateHtmlReport -Label 'Create HTML report' -Key R -Primary ` + -Description 'Generate an assignment report and optional CSV companion.' ` + -Commands @('New-IntuneHTMLReport') + New-IACTuiActionDefinition -Id MigrateRecords -Label 'Migrate records' -Key M ` + -Description 'Convert saved version 1 records to the canonical version 2 schema.' ` + -Commands @('ConvertTo-IntuneAssignmentRecord') + New-IACTuiActionDefinition -Id ExportWorkspaceData -Label 'Save current results' -Key E ` + -Description 'Export loaded workspace results as JSON, JSON Lines, or CSV.' + ) + + New-IACTuiFeatureDefinition -Id Settings -Title 'Settings' -Capabilities Core ` + -Summary 'Manage tenant connection, capability profile, diagnostics, and local data.' ` + -Actions @( + New-IACTuiActionDefinition -Id ConnectTenant -Label 'Connect tenant' -Key C -Primary ` + -Description 'Connect with a least-privilege capability profile.' ` + -Commands @('Connect-IntuneAssignmentChecker') + New-IACTuiActionDefinition -Id SwitchTenant -Label 'Switch tenant' -Key T ` + -Description 'Clear tenant-scoped state and connect to another tenant.' ` + -Commands @('Switch-IntuneAssignmentCheckerTenant') + New-IACTuiActionDefinition -Id RunDiagnostics -Label 'Run diagnostics' -Key D ` + -Description 'Validate the runtime, Graph connection, capabilities, and beta workloads.' ` + -Commands @('Test-IntuneAssignmentCheckerEnvironment') + New-IACTuiActionDefinition -Id RefreshSettingDefinitions -Label 'Refresh setting catalog' -Key U ` + -Description 'Refresh the local Settings Catalog definition cache.' ` + -Commands @('Update-IntuneSettingDefinition') + New-IACTuiActionDefinition -Id ConfigureScopeFilter -Label 'Set scope filter' -Key S ` + -Description 'Limit supported assignment searches to one scope-tag name.' + ) + ) +} + +function Test-IACTuiFeatureParity { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string[]]$CommandName, + [object[]]$Feature = @(Get-IACTuiFeatureRegistry) + ) + + $mapped = @($Feature.Commands | Where-Object { $_ } | Sort-Object -Unique) + $expected = @($CommandName | Sort-Object -Unique) + $missing = @($expected | Where-Object { $_ -notin $mapped }) + $unknown = @($mapped | Where-Object { $_ -notin $expected }) + $duplicates = @($Feature.Commands | Group-Object | Where-Object Count -GT 1 | ForEach-Object Name) + + [PSCustomObject][ordered]@{ + Complete = $missing.Count -eq 0 -and $unknown.Count -eq 0 + Expected = $expected + Mapped = $mapped + Missing = $missing + Unknown = $unknown + Duplicates = $duplicates + } +} diff --git a/Module/IntuneAssignmentChecker/Private/TuiInput.ps1 b/Module/IntuneAssignmentChecker/Private/TuiInput.ps1 new file mode 100644 index 0000000..c33d699 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/TuiInput.ps1 @@ -0,0 +1,314 @@ +function Test-IACVirtualTerminal { + [CmdletBinding()] + param() + + try { return [bool]$Host.UI.SupportsVirtualTerminal } + catch { return $false } +} + +function Get-IACWindowsTuiInputMode { + [CmdletBinding()] + param([Parameter(Mandatory)][uint32]$Mode) + + # ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_VIRTUAL_TERMINAL_INPUT, + # with QUICK_EDIT disabled so conhost does not consume clicks for selection. + $result = [uint32]($Mode -bor [uint32]0x0290) + if (($result -band [uint32]0x0040) -ne 0) { $result = [uint32]($result -bxor [uint32]0x0040) } + return $result +} + +function Initialize-IACWindowsConsoleInterop { + [CmdletBinding()] + param() + + if (-not $IsWindows -or ('IntuneAssignmentChecker.NativeConsole' -as [type])) { return } + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace IntuneAssignmentChecker +{ + internal static class NativeConsole + { + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern IntPtr GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); + } +} +'@ -ErrorAction Stop +} + +function Enable-IACTuiTerminal { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [switch]$DisableMouse + ) + + $virtualTerminal = Test-IACVirtualTerminal + $terminalState = [PSCustomObject][ordered]@{ + VirtualTerminal = $virtualTerminal + ColorEnabled = $virtualTerminal -and -not $env:NO_COLOR + MouseEnabled = $false + WindowsInputHandle = [IntPtr]::Zero + WindowsOriginalMode = [uint32]0 + WindowsModeChanged = $false + OriginalCursorVisible = $true + } + try { $terminalState.OriginalCursorVisible = [Console]::CursorVisible } catch { } + + if ($virtualTerminal -and $IsWindows -and -not $DisableMouse) { + try { + Initialize-IACWindowsConsoleInterop + $handle = [IntuneAssignmentChecker.NativeConsole]::GetStdHandle(-10) + $mode = [uint32]0 + if ($handle -ne [IntPtr]::Zero -and [IntuneAssignmentChecker.NativeConsole]::GetConsoleMode($handle, [ref]$mode)) { + # ENABLE_VIRTUAL_TERMINAL_INPUT makes SGR mouse sequences available + # through Console.ReadKey in Windows Terminal and modern conhost. + $virtualInputMode = Get-IACWindowsTuiInputMode -Mode $mode + if ([IntuneAssignmentChecker.NativeConsole]::SetConsoleMode($handle, $virtualInputMode)) { + $terminalState.WindowsInputHandle = $handle + $terminalState.WindowsOriginalMode = $mode + $terminalState.WindowsModeChanged = $true + $terminalState.MouseEnabled = $true + } + } + } + catch { + $terminalState.MouseEnabled = $false + } + } + elseif ($virtualTerminal -and -not $DisableMouse) { + $terminalState.MouseEnabled = $true + } + + $State.Terminal = $terminalState + if ($virtualTerminal) { + $sequence = "`e[?1049h`e[?25l`e[2J`e[H" + if ($terminalState.MouseEnabled) { + # Button events, drag events, and SGR coordinates. SGR avoids the + # 223-column limitation of the original X10 mouse encoding. + $sequence += "`e[?1000h`e[?1002h`e[?1006h" + } + [Console]::Write($sequence) + } + else { + try { [Console]::CursorVisible = $false } catch { } + Clear-Host + } + return $terminalState +} + +function Disable-IACTuiTerminal { + [CmdletBinding()] + param([Parameter(Mandatory)]$State) + + $terminalState = $State.Terminal + if ($terminalState -and $terminalState.VirtualTerminal) { + $sequence = "`e[0m" + if ($terminalState.MouseEnabled) { $sequence += "`e[?1006l`e[?1002l`e[?1000l" } + $sequence += "`e[?25h`e[?1049l" + [Console]::Write($sequence) + } + else { + try { [Console]::CursorVisible = if ($terminalState) { $terminalState.OriginalCursorVisible } else { $true } } catch { } + Clear-Host + } + + if ($terminalState -and $terminalState.WindowsModeChanged) { + try { + $null = [IntuneAssignmentChecker.NativeConsole]::SetConsoleMode( + $terminalState.WindowsInputHandle, + $terminalState.WindowsOriginalMode + ) + } + catch { } + } +} + +function New-IACTuiKeyEvent { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Key, + [AllowEmptyString()][string]$Character = '', + [System.ConsoleModifiers]$Modifiers = [System.ConsoleModifiers]0 + ) + + [PSCustomObject][ordered]@{ + Kind = 'Key' + Key = $Key + Character = $Character + Modifiers = $Modifiers + } +} + +function Test-IACTuiControlCEvent { + [CmdletBinding()] + param([Parameter(Mandatory)]$InputEvent) + + return $InputEvent.Kind -eq 'Key' -and $InputEvent.Key -eq 'C' -and + (($InputEvent.Modifiers -band [ConsoleModifiers]::Control) -ne 0) +} + +function ConvertFrom-IACTuiInputSequence { + [CmdletBinding()] + param([Parameter(Mandatory)][AllowEmptyString()][string]$Sequence) + + if ([string]::IsNullOrEmpty($Sequence) -or $Sequence -eq "`e") { + return New-IACTuiKeyEvent -Key Escape + } + + $mouseMatch = [regex]::Match($Sequence, '^\x1b\[<(\d+);(\d+);(\d+)([Mm])$') + if ($mouseMatch.Success) { + $code = [int]$mouseMatch.Groups[1].Value + $x = [int]$mouseMatch.Groups[2].Value - 1 + $y = [int]$mouseMatch.Groups[3].Value - 1 + $terminator = $mouseMatch.Groups[4].Value + $button = 'Unknown' + $action = if ($terminator -ceq 'm') { 'Up' } elseif (($code -band 32) -ne 0) { 'Move' } else { 'Down' } + $wheelDelta = 0 + + if (($code -band 64) -ne 0) { + $button = 'Wheel' + $action = 'Wheel' + $wheelDelta = if (($code -band 1) -eq 0) { 1 } else { -1 } + } + else { + $button = switch ($code -band 3) { + 0 { 'Left' } + 1 { 'Middle' } + 2 { 'Right' } + 3 { 'None' } + } + } + + return [PSCustomObject][ordered]@{ + Kind = 'Mouse' + Key = $null + Character = '' + X = $x + Y = $y + Button = $button + Action = $action + WheelDelta = $wheelDelta + Shift = ($code -band 4) -ne 0 + Alt = ($code -band 8) -ne 0 + Control = ($code -band 16) -ne 0 + Sequence = $Sequence + } + } + + $knownSequence = @{ + "`e[A" = 'UpArrow' + "`e[B" = 'DownArrow' + "`e[C" = 'RightArrow' + "`e[D" = 'LeftArrow' + "`e[5~" = 'PageUp' + "`e[6~" = 'PageDown' + "`e[H" = 'Home' + "`e[F" = 'End' + } + if ($knownSequence.ContainsKey($Sequence)) { + return New-IACTuiKeyEvent -Key $knownSequence[$Sequence] + } + return [PSCustomObject][ordered]@{ + Kind = 'Unknown' + Key = $null + Character = '' + Sequence = $Sequence + } +} + +function Read-IACTuiInput { + [CmdletBinding()] + param() + + $restoreControlCMode = $false + $originalControlCMode = $false + try { + try { + $originalControlCMode = [Console]::TreatControlCAsInput + [Console]::TreatControlCAsInput = $true + $restoreControlCMode = $true + } + catch { } + + $key = [Console]::ReadKey($true) + if ($key.Key -eq [ConsoleKey]::Escape) { + $sequence = [Text.StringBuilder]::new() + [void]$sequence.Append("`e") + $deadline = [datetime]::UtcNow.AddMilliseconds(50) + $idleDeadline = $deadline + while ([datetime]::UtcNow -lt $deadline) { + if ([Console]::KeyAvailable) { + $next = [Console]::ReadKey($true) + [void]$sequence.Append($next.KeyChar) + $idleDeadline = [datetime]::UtcNow.AddMilliseconds(6) + $current = $sequence.ToString() + if ($current -match '^\x1b\[<\d+;\d+;\d+[Mm]$' -or + $current -in @("`e[A", "`e[B", "`e[C", "`e[D", "`e[H", "`e[F", "`e[5~", "`e[6~")) { + break + } + } + elseif ($sequence.Length -gt 1 -and [datetime]::UtcNow -ge $idleDeadline) { break } + else { Start-Sleep -Milliseconds 1 } + } + return ConvertFrom-IACTuiInputSequence -Sequence $sequence.ToString() + } + + return New-IACTuiKeyEvent -Key "$($key.Key)" -Character "$($key.KeyChar)" -Modifiers $key.Modifiers + } + finally { + if ($restoreControlCMode) { + try { [Console]::TreatControlCAsInput = $originalControlCMode } + catch { } + } + } +} + +function Add-IACTuiHitTarget { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][ValidateRange(0, 10000)][int]$X, + [Parameter(Mandatory)][ValidateRange(0, 10000)][int]$Y, + [Parameter(Mandatory)][ValidateRange(1, 10000)][int]$Width, + [Parameter(Mandatory)][ValidateRange(1, 10000)][int]$Height, + [Parameter(Mandatory)][string]$Action, + [AllowNull()]$Value + ) + + [void]$State.HitTargets.Add([PSCustomObject][ordered]@{ + X = $X + Y = $Y + Width = $Width + Height = $Height + Action = $Action + Value = $Value + }) +} + +function Get-IACTuiHitTarget { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y + ) + + for ($index = $State.HitTargets.Count - 1; $index -ge 0; $index--) { + $target = $State.HitTargets[$index] + if ($X -ge $target.X -and $X -lt ($target.X + $target.Width) -and + $Y -ge $target.Y -and $Y -lt ($target.Y + $target.Height)) { + return $target + } + } + return $null +} diff --git a/Module/IntuneAssignmentChecker/Private/TuiRenderer.ps1 b/Module/IntuneAssignmentChecker/Private/TuiRenderer.ps1 new file mode 100644 index 0000000..438aeb7 --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/TuiRenderer.ps1 @@ -0,0 +1,494 @@ +function Get-IACTuiAnsiStyle { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Style) + + switch ($Style) { + 'Canvas' { "`e[38;2;223;229;236m`e[48;2;10;13;18m" } + 'Panel' { "`e[38;2;223;229;236m`e[48;2;14;19;26m" } + 'PanelRaised' { "`e[38;2;223;229;236m`e[48;2;18;25;35m" } + 'Border' { "`e[38;2;41;52;68m`e[48;2;14;19;26m" } + 'Text' { "`e[38;2;223;229;236m`e[48;2;14;19;26m" } + 'Muted' { "`e[38;2;126;139;155m`e[48;2;14;19;26m" } + 'Accent' { "`e[38;2;244;184;96m`e[48;2;14;19;26m" } + 'AccentStrong' { "`e[1;38;2;33;21;4m`e[48;2;244;184;96m" } + 'Blue' { "`e[38;2;116;182;239m`e[48;2;14;19;26m" } + 'Success' { "`e[38;2;128;214;164m`e[48;2;14;19;26m" } + 'Error' { "`e[38;2;255;119;130m`e[48;2;14;19;26m" } + 'Warning' { "`e[38;2;244;184;96m`e[48;2;14;19;26m" } + 'Selected' { "`e[1;38;2;223;229;236m`e[48;2;41;52;68m" } + default { "`e[38;2;223;229;236m`e[48;2;10;13;18m" } + } +} + +function New-IACTuiBuffer { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateRange(1, 1000)][int]$Width, + [Parameter(Mandatory)][ValidateRange(1, 500)][int]$Height + ) + + $characters = [char[,]]::new($Height, $Width) + $styles = [string[,]]::new($Height, $Width) + for ($y = 0; $y -lt $Height; $y++) { + for ($x = 0; $x -lt $Width; $x++) { + $characters[$y, $x] = ' ' + $styles[$y, $x] = 'Canvas' + } + } + [PSCustomObject]@{ + Width = $Width + Height = $Height + Characters = $characters + Styles = $styles + } +} + +function Set-IACTuiBufferCell { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [Parameter(Mandatory)][char]$Character, + [Parameter(Mandatory)][string]$Style + ) + + if ($X -lt 0 -or $X -ge $Buffer.Width -or $Y -lt 0 -or $Y -ge $Buffer.Height) { return } + $Buffer.Characters[$Y, $X] = $Character + $Buffer.Styles[$Y, $X] = $Style +} + +function Write-IACTuiBufferFill { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [Parameter(Mandatory)][int]$Width, + [Parameter(Mandatory)][int]$Height, + [Parameter(Mandatory)][string]$Style, + [char]$Character = ' ' + ) + + $right = [math]::Min($Buffer.Width, $X + $Width) + $bottom = [math]::Min($Buffer.Height, $Y + $Height) + for ($row = [math]::Max(0, $Y); $row -lt $bottom; $row++) { + for ($column = [math]::Max(0, $X); $column -lt $right; $column++) { + $Buffer.Characters[$row, $column] = $Character + $Buffer.Styles[$row, $column] = $Style + } + } +} + +function ConvertTo-IACTuiDisplayText { + [CmdletBinding()] + param( + [AllowNull()]$Value, + [Parameter(Mandatory)][ValidateRange(0, 10000)][int]$Width, + [switch]$Pad + ) + + if ($Width -eq 0) { return '' } + $text = if ($null -eq $Value) { '' } else { "$Value" -replace '[\r\n\t]', ' ' } + if ($text.Length -gt $Width) { + $text = if ($Width -eq 1) { '…' } else { $text.Substring(0, $Width - 1) + '…' } + } + if ($Pad) { return $text.PadRight($Width) } + return $text +} + +function Write-IACTuiBufferText { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [AllowEmptyString()][string]$Text, + [string]$Style = 'Text', + [int]$MaxWidth = 0 + ) + + if ($Y -lt 0 -or $Y -ge $Buffer.Height -or $X -ge $Buffer.Width) { return } + $available = $Buffer.Width - [math]::Max(0, $X) + if ($MaxWidth -gt 0) { $available = [math]::Min($available, $MaxWidth) } + $display = ConvertTo-IACTuiDisplayText -Value $Text -Width ([math]::Max(0, $available)) + for ($index = 0; $index -lt $display.Length; $index++) { + Set-IACTuiBufferCell -Buffer $Buffer -X ($X + $index) -Y $Y -Character $display[$index] -Style $Style + } +} + +function Write-IACTuiBox { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [Parameter(Mandatory)][ValidateRange(2, 10000)][int]$Width, + [Parameter(Mandatory)][ValidateRange(2, 10000)][int]$Height, + [string]$Style = 'Panel', + [string]$BorderStyle = 'Border' + ) + + Write-IACTuiBufferFill -Buffer $Buffer -X $X -Y $Y -Width $Width -Height $Height -Style $Style + for ($column = $X; $column -lt ($X + $Width); $column++) { + Set-IACTuiBufferCell -Buffer $Buffer -X $column -Y $Y -Character '-' -Style $BorderStyle + Set-IACTuiBufferCell -Buffer $Buffer -X $column -Y ($Y + $Height - 1) -Character '-' -Style $BorderStyle + } + for ($row = $Y; $row -lt ($Y + $Height); $row++) { + Set-IACTuiBufferCell -Buffer $Buffer -X $X -Y $row -Character '|' -Style $BorderStyle + Set-IACTuiBufferCell -Buffer $Buffer -X ($X + $Width - 1) -Y $row -Character '|' -Style $BorderStyle + } + foreach ($corner in @( + [PSCustomObject]@{ X = $X; Y = $Y } + [PSCustomObject]@{ X = $X + $Width - 1; Y = $Y } + [PSCustomObject]@{ X = $X; Y = $Y + $Height - 1 } + [PSCustomObject]@{ X = $X + $Width - 1; Y = $Y + $Height - 1 } + )) { + Set-IACTuiBufferCell -Buffer $Buffer -X $corner.X -Y $corner.Y -Character '+' -Style $BorderStyle + } +} + +function ConvertTo-IACTuiBufferText { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [switch]$Ansi + ) + + $output = [Text.StringBuilder]::new() + $activeStyle = '' + for ($y = 0; $y -lt $Buffer.Height; $y++) { + for ($x = 0; $x -lt $Buffer.Width; $x++) { + $style = $Buffer.Styles[$y, $x] + if ($Ansi -and $style -ne $activeStyle) { + [void]$output.Append((Get-IACTuiAnsiStyle -Style $style)) + $activeStyle = $style + } + [void]$output.Append($Buffer.Characters[$y, $x]) + } + if ($Ansi) { [void]$output.Append("`e[0m") } + if ($y -lt ($Buffer.Height - 1)) { [void]$output.Append("`n") } + $activeStyle = '' + } + $output.ToString() +} + +function Get-IACTuiStatusStyle { + [CmdletBinding()] + param([AllowNull()]$Value) + + switch -Regex ("$Value") { + 'Critical|High|Failed|Failure|Error|Conflict' { 'Error'; break } + 'Passed|Success|Healthy|Available|Complete|Applied|Installed' { 'Success'; break } + 'Medium|Warning|Pending|Stale|Unknown|Incomplete|Skipped' { 'Warning'; break } + default { 'Blue' } + } +} + +function Write-IACTuiButton { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [Parameter(Mandatory)]$Action, + [switch]$Primary + ) + + $label = " $($Action.Key) $($Action.Label) " + $style = if ($Primary) { 'AccentStrong' } else { 'Selected' } + Write-IACTuiBufferText -Buffer $Buffer -X $X -Y $Y -Text $label -Style $style + Add-IACTuiHitTarget -State $State -X $X -Y $Y -Width $label.Length -Height 1 -Action InvokeAction -Value $Action.Id + return $label.Length +} + +function Write-IACTuiHeader { + [CmdletBinding()] + param([Parameter(Mandatory)]$Buffer, [Parameter(Mandatory)]$State) + + Write-IACTuiBufferFill -Buffer $Buffer -X 0 -Y 0 -Width $Buffer.Width -Height 3 -Style Panel + Write-IACTuiBufferText -Buffer $Buffer -X 2 -Y 0 -Text 'INTUNE ASSIGNMENT CHECKER' -Style Accent -MaxWidth 30 + Write-IACTuiBufferText -Buffer $Buffer -X 2 -Y 1 -Text 'Assignment governance command center' -Style Muted -MaxWidth 42 + + $tenant = if ($script:CurrentTenantName) { $script:CurrentTenantName } elseif ($script:CurrentTenantId) { $script:CurrentTenantId } else { 'Not connected' } + $connectionStyle = if ($tenant -eq 'Not connected') { 'Warning' } else { 'Success' } + $connection = "● $tenant" + $connectionX = [math]::Max(2, $Buffer.Width - $connection.Length - 3) + Write-IACTuiBufferText -Buffer $Buffer -X $connectionX -Y 0 -Text $connection -Style $connectionStyle + Add-IACTuiHitTarget -State $State -X $connectionX -Y 0 -Width $connection.Length -Height 2 -Action Navigate -Value Settings + + $capability = if (@($script:RequestedCapabilities).Count -gt 0) { @($script:RequestedCapabilities) -join ', ' } else { 'Not selected' } + $scope = if ($State.Settings.ScopeTagFilter) { $State.Settings.ScopeTagFilter } else { 'All scope tags' } + $capabilityText = "Profile: $capability · $scope" + Write-IACTuiBufferText -Buffer $Buffer -X ([math]::Max(2, $Buffer.Width - $capabilityText.Length - 3)) -Y 1 -Text $capabilityText -Style Muted +} + +function Write-IACTuiNavigation { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][int]$Top, + [Parameter(Mandatory)][int]$Height, + [Parameter(Mandatory)][int]$Width + ) + + Write-IACTuiBufferFill -Buffer $Buffer -X 0 -Y $Top -Width $Width -Height $Height -Style Panel + Write-IACTuiBufferText -Buffer $Buffer -X 2 -Y ($Top + 1) -Text 'WORKSPACES' -Style Muted -MaxWidth ($Width - 4) + $availableRows = [math]::Max(1, $Height - 4) + $features = @($State.Registry) + if ($State.NavigationIndex -lt $State.NavigationOffset) { $State.NavigationOffset = $State.NavigationIndex } + if ($State.NavigationIndex -ge ($State.NavigationOffset + $availableRows)) { + $State.NavigationOffset = $State.NavigationIndex - $availableRows + 1 + } + $State.NavigationOffset = [math]::Max(0, [math]::Min($State.NavigationOffset, [math]::Max(0, $features.Count - $availableRows))) + + for ($row = 0; $row -lt $availableRows; $row++) { + $index = $State.NavigationOffset + $row + if ($index -ge $features.Count) { break } + $feature = $features[$index] + $style = if ($feature.Id -eq $State.ActiveViewId) { 'AccentStrong' } elseif ($State.Focus -eq 'Navigation' -and $index -eq $State.NavigationIndex) { 'Selected' } else { 'Text' } + $prefix = if ($feature.Id -eq $State.ActiveViewId) { '›' } else { ' ' } + $text = ConvertTo-IACTuiDisplayText -Value "$prefix $($feature.Title)" -Width ($Width - 2) -Pad + Write-IACTuiBufferText -Buffer $Buffer -X 1 -Y ($Top + 2 + $row) -Text $text -Style $style + Add-IACTuiHitTarget -State $State -X 1 -Y ($Top + 2 + $row) -Width ($Width - 2) -Height 1 -Action Navigate -Value $feature.Id + } + Write-IACTuiBufferText -Buffer $Buffer -X 2 -Y ($Top + $Height - 1) -Text '↑↓ move Enter open' -Style Muted -MaxWidth ($Width - 4) +} + +function Write-IACTuiMetricCard { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [Parameter(Mandatory)][int]$Width, + [Parameter(Mandatory)][string]$Label, + [AllowNull()]$Value, + [AllowNull()][string]$Hint, + [string]$Style = 'Blue' + ) + + Write-IACTuiBox -Buffer $Buffer -X $X -Y $Y -Width $Width -Height 5 -Style PanelRaised + Write-IACTuiBufferText -Buffer $Buffer -X ($X + 2) -Y ($Y + 1) -Text $Label.ToUpperInvariant() -Style Muted -MaxWidth ($Width - 4) + Write-IACTuiBufferText -Buffer $Buffer -X ($X + 2) -Y ($Y + 2) -Text $(if ($null -eq $Value -or "$Value" -eq '') { '—' } else { "$Value" }) -Style $Style -MaxWidth ($Width - 4) + Write-IACTuiBufferText -Buffer $Buffer -X ($X + 2) -Y ($Y + 3) -Text "$Hint" -Style Muted -MaxWidth ($Width - 4) +} + +function Write-IACTuiOverview { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [Parameter(Mandatory)][int]$Width, + [Parameter(Mandatory)][int]$Height + ) + + $gap = 1 + $cardWidth = [math]::Max(14, [math]::Floor(($Width - (3 * $gap)) / 4)) + $cards = @( + @{ Label = 'Coverage'; Value = $State.Metrics.Coverage; Hint = 'workloads scanned'; Style = 'Blue' } + @{ Label = 'Critical findings'; Value = $State.Metrics.Critical; Hint = 'needs attention'; Style = 'Error' } + @{ Label = 'Drift'; Value = $State.Metrics.Drift; Hint = 'since baseline'; Style = 'Warning' } + @{ Label = 'Delivery health'; Value = $State.Metrics.Health; Hint = 'successful'; Style = 'Success' } + ) + for ($index = 0; $index -lt $cards.Count; $index++) { + $cardX = $X + ($index * ($cardWidth + $gap)) + $actualWidth = if ($index -eq ($cards.Count - 1)) { [math]::Max(2, $X + $Width - $cardX) } else { $cardWidth } + $card = $cards[$index] + Write-IACTuiMetricCard -Buffer $Buffer -X $cardX -Y $Y -Width $actualWidth ` + -Label $card.Label -Value $card.Value -Hint $card.Hint -Style $card.Style + } + + $contentY = $Y + 6 + $contentHeight = [math]::Max(4, $Height - 6) + Write-IACTuiBox -Buffer $Buffer -X $X -Y $contentY -Width $Width -Height $contentHeight -Style Panel + $overviewHeading = if ($State.Filter) { "PRIORITY FINDINGS · FILTER: $($State.Filter)" } else { 'PRIORITY FINDINGS' } + Write-IACTuiBufferText -Buffer $Buffer -X ($X + 2) -Y ($contentY + 1) -Text $overviewHeading -Style Accent -MaxWidth ($Width - 4) + $rows = @(Get-IACTuiViewRows -State $State -ViewId Overview) + if ($rows.Count -eq 0) { + Write-IACTuiBufferText -Buffer $Buffer -X ($X + 2) -Y ($contentY + 3) -Text 'No posture data loaded yet.' -Style Text -MaxWidth ($Width - 4) + Write-IACTuiBufferText -Buffer $Buffer -X ($X + 2) -Y ($contentY + 4) -Text 'Choose Refresh posture to scan the connected tenant.' -Style Muted -MaxWidth ($Width - 4) + return + } + Write-IACTuiRows -Buffer $Buffer -State $State -Rows $rows -X ($X + 1) -Y ($contentY + 2) -Width ($Width - 2) -Height ($contentHeight - 3) +} + +function Write-IACTuiRows { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Rows, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [Parameter(Mandatory)][int]$Width, + [Parameter(Mandatory)][int]$Height + ) + + if ($Rows.Count -eq 0) { + Write-IACTuiBufferText -Buffer $Buffer -X ($X + 1) -Y ($Y + 1) -Text 'No results loaded. Choose an action above to begin.' -Style Muted -MaxWidth ($Width - 2) + return + } + $visibleCount = [math]::Max(1, $Height) + if ($State.SelectedIndex -lt $State.RowOffset) { $State.RowOffset = $State.SelectedIndex } + if ($State.SelectedIndex -ge ($State.RowOffset + $visibleCount)) { $State.RowOffset = $State.SelectedIndex - $visibleCount + 1 } + $State.RowOffset = [math]::Max(0, [math]::Min($State.RowOffset, [math]::Max(0, $Rows.Count - $visibleCount))) + $statusWidth = [math]::Min(14, [math]::Max(8, [math]::Floor($Width * 0.2))) + for ($displayIndex = 0; $displayIndex -lt $visibleCount; $displayIndex++) { + $index = $State.RowOffset + $displayIndex + if ($index -ge $Rows.Count) { break } + $row = $Rows[$index] + $selected = $State.Focus -eq 'Content' -and $index -eq $State.SelectedIndex + $style = if ($selected) { 'Selected' } else { 'Text' } + $titleWidth = [math]::Max(10, $Width - $statusWidth - 4) + $title = ConvertTo-IACTuiDisplayText -Value " $($row.Title)" -Width $titleWidth -Pad + $status = ConvertTo-IACTuiDisplayText -Value "$($row.Status)" -Width $statusWidth -Pad + Write-IACTuiBufferText -Buffer $Buffer -X $X -Y ($Y + $displayIndex) -Text $title -Style $style + Write-IACTuiBufferText -Buffer $Buffer -X ($X + $titleWidth + 1) -Y ($Y + $displayIndex) -Text $status -Style $(if ($selected) { 'Selected' } else { Get-IACTuiStatusStyle -Value $row.Status }) + Add-IACTuiHitTarget -State $State -X $X -Y ($Y + $displayIndex) -Width $Width -Height 1 -Action SelectRow -Value $index + } +} + +function Write-IACTuiWorkspace { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Buffer, + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][int]$X, + [Parameter(Mandatory)][int]$Y, + [Parameter(Mandatory)][int]$Width, + [Parameter(Mandatory)][int]$Height + ) + + $feature = $State.Registry | Where-Object Id -EQ $State.ActiveViewId | Select-Object -First 1 + Write-IACTuiBufferText -Buffer $Buffer -X $X -Y $Y -Text $feature.Title -Style Accent -MaxWidth $Width + Write-IACTuiBufferText -Buffer $Buffer -X $X -Y ($Y + 1) -Text $feature.Summary -Style Muted -MaxWidth $Width + $buttonX = $X + $buttonY = $Y + 3 + $buttonRows = 1 + foreach ($action in @($feature.Actions)) { + $buttonWidth = " $($action.Key) $($action.Label) ".Length + if (($buttonX + $buttonWidth) -gt ($X + $Width)) { + $buttonRows++ + $buttonY++ + $buttonX = $X + } + $used = Write-IACTuiButton -Buffer $Buffer -State $State -X $buttonX -Y $buttonY -Action $action -Primary:$action.Primary + $buttonX += $used + 1 + } + + $contentY = $Y + 4 + $buttonRows + $contentHeight = [math]::Max(3, $Height - 4 - $buttonRows) + if ($State.ActiveViewId -eq 'Overview') { + Write-IACTuiOverview -Buffer $Buffer -State $State -X $X -Y $contentY -Width $Width -Height $contentHeight + return + } + + $rows = @(Get-IACTuiViewRows -State $State) + $listWidth = if ($Width -ge 60) { [math]::Floor($Width * 0.55) } else { $Width } + Write-IACTuiBox -Buffer $Buffer -X $X -Y $contentY -Width $listWidth -Height $contentHeight -Style Panel + $noticeCount = @($State.Notices[$State.ActiveViewId]).Count + $resultHeading = if ($State.Filter) { "RESULTS · FILTER: $($State.Filter)" } else { 'RESULTS' } + if ($noticeCount -gt 0) { $resultHeading += " · $noticeCount NOTICE$(if ($noticeCount -ne 1) { 'S' })" } + Write-IACTuiBufferText -Buffer $Buffer -X ($X + 2) -Y ($contentY + 1) -Text $resultHeading -Style Muted -MaxWidth ($listWidth - 4) + $rowStart = $contentY + 2 + $rowHeight = $contentHeight - 3 + if ($noticeCount -gt 0) { + $firstNotice = @($State.Notices[$State.ActiveViewId])[0] + Write-IACTuiBufferText -Buffer $Buffer -X ($X + 2) -Y $rowStart -Text "! $($firstNotice.Message)" ` + -Style $(if ($firstNotice.Status -eq 'Error') { 'Error' } else { 'Warning' }) -MaxWidth ($listWidth - 4) + $rowStart++ + $rowHeight = [math]::Max(1, $rowHeight - 1) + } + Write-IACTuiRows -Buffer $Buffer -State $State -Rows $rows -X ($X + 1) -Y $rowStart -Width ($listWidth - 2) -Height $rowHeight + + if ($listWidth -lt $Width) { + $detailX = $X + $listWidth + 1 + $detailWidth = $Width - $listWidth - 1 + Write-IACTuiBox -Buffer $Buffer -X $detailX -Y $contentY -Width $detailWidth -Height $contentHeight -Style PanelRaised + Write-IACTuiBufferText -Buffer $Buffer -X ($detailX + 2) -Y ($contentY + 1) -Text 'DETAIL' -Style Muted -MaxWidth ($detailWidth - 4) + if ($rows.Count -gt 0) { + $selectedIndex = [math]::Max(0, [math]::Min($State.SelectedIndex, $rows.Count - 1)) + $selected = $rows[$selectedIndex] + Write-IACTuiBufferText -Buffer $Buffer -X ($detailX + 2) -Y ($contentY + 3) -Text $selected.Title -Style Accent -MaxWidth ($detailWidth - 4) + $detailRow = $contentY + 5 + foreach ($line in @($selected.DetailLines)) { + if ($detailRow -ge ($contentY + $contentHeight - 1)) { break } + Write-IACTuiBufferText -Buffer $Buffer -X ($detailX + 2) -Y $detailRow -Text "$line" -Style Text -MaxWidth ($detailWidth - 4) + $detailRow++ + } + } + else { + Write-IACTuiBufferText -Buffer $Buffer -X ($detailX + 2) -Y ($contentY + 3) -Text 'Select an action to load data.' -Style Muted -MaxWidth ($detailWidth - 4) + } + } +} + +function Write-IACTuiFooter { + [CmdletBinding()] + param([Parameter(Mandatory)]$Buffer, [Parameter(Mandatory)]$State) + + $y = $Buffer.Height - 2 + Write-IACTuiBufferFill -Buffer $Buffer -X 0 -Y $y -Width $Buffer.Width -Height 2 -Style Panel + $messageStyle = if ($State.Busy) { 'Warning' } else { $State.StatusStyle } + $message = if ($State.Busy) { 'Working…' } elseif ($State.StatusMessage) { $State.StatusMessage } else { 'Ready' } + Write-IACTuiBufferText -Buffer $Buffer -X 2 -Y $y -Text $message -Style $messageStyle -MaxWidth ($Buffer.Width - 4) + $help = 'Tab focus ↑↓ navigate Enter open R run / filter Esc back ? help Q quit' + Write-IACTuiBufferText -Buffer $Buffer -X 2 -Y ($y + 1) -Text $help -Style Muted -MaxWidth ($Buffer.Width - 4) +} + +function Get-IACTuiFrame { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][ValidateRange(1, 10000)][int]$Width, + [Parameter(Mandatory)][ValidateRange(1, 10000)][int]$Height, + [switch]$Ansi + ) + + $Width = [math]::Min(1000, $Width) + $Height = [math]::Min(500, $Height) + $State.HitTargets.Clear() + $buffer = New-IACTuiBuffer -Width $Width -Height $Height + if ($Width -lt 4 -or $Height -lt 4) { + return ConvertTo-IACTuiBufferText -Buffer $buffer -Ansi:$Ansi + } + if ($Width -lt 90 -or $Height -lt 26) { + Write-IACTuiBox -Buffer $buffer -X 1 -Y 1 -Width ($Width - 2) -Height ($Height - 2) -Style Panel + Write-IACTuiBufferText -Buffer $buffer -X 4 -Y 3 -Text 'INTUNE ASSIGNMENT CHECKER' -Style Accent -MaxWidth ($Width - 8) + Write-IACTuiBufferText -Buffer $buffer -X 4 -Y 6 -Text 'Terminal is too small for the command center.' -Style Warning -MaxWidth ($Width - 8) + Write-IACTuiBufferText -Buffer $buffer -X 4 -Y 8 -Text "Current: ${Width}x${Height} Required: 90x26" -Style Muted -MaxWidth ($Width - 8) + return ConvertTo-IACTuiBufferText -Buffer $buffer -Ansi:$Ansi + } + + Write-IACTuiHeader -Buffer $buffer -State $State + $footerHeight = 2 + $bodyTop = 3 + $bodyHeight = $Height - $bodyTop - $footerHeight + $navigationWidth = [math]::Min(25, [math]::Max(22, [math]::Floor($Width * 0.2))) + $State.LastNavigationWidth = $navigationWidth + Write-IACTuiNavigation -Buffer $buffer -State $State -Top $bodyTop -Height $bodyHeight -Width $navigationWidth + $workspaceX = $navigationWidth + 2 + Write-IACTuiWorkspace -Buffer $buffer -State $State -X $workspaceX -Y ($bodyTop + 1) -Width ($Width - $workspaceX - 2) -Height ($bodyHeight - 2) + Write-IACTuiFooter -Buffer $buffer -State $State + ConvertTo-IACTuiBufferText -Buffer $buffer -Ansi:$Ansi +} + +function Show-IACTuiFrame { + [CmdletBinding()] + param([Parameter(Mandatory)]$State) + + $width = [math]::Max(1, $(try { [Console]::WindowWidth } catch { 120 })) + $height = [math]::Max(1, $(try { [Console]::WindowHeight } catch { 36 })) + $ansi = [bool]($State.Terminal -and $State.Terminal.ColorEnabled) + $frame = Get-IACTuiFrame -State $State -Width $width -Height $height -Ansi:$ansi + if ($State.Terminal -and $State.Terminal.VirtualTerminal) { [Console]::Write("`e[H$frame") } + else { + Clear-Host + [Console]::Write($frame) + } +} diff --git a/Module/IntuneAssignmentChecker/Private/TuiWorkflows.ps1 b/Module/IntuneAssignmentChecker/Private/TuiWorkflows.ps1 new file mode 100644 index 0000000..95d6e8c --- /dev/null +++ b/Module/IntuneAssignmentChecker/Private/TuiWorkflows.ps1 @@ -0,0 +1,1163 @@ +function New-IACTuiState { + [CmdletBinding()] + param( + [ValidateSet('Overview', 'Assignments', 'Governance', 'Simulator', 'Drift', 'Health', 'Access', 'Filters', 'Fleet', 'Reports', 'Settings')] + [string]$InitialView = 'Overview' + ) + + $registry = @(Get-IACTuiFeatureRegistry) + $rows = @{} + $notices = @{} + foreach ($feature in $registry) { $rows[$feature.Id] = @(); $notices[$feature.Id] = @() } + $navigationIndex = [array]::IndexOf(@($registry.Id), $InitialView) + if ($navigationIndex -lt 0) { $navigationIndex = 0 } + + [PSCustomObject][ordered]@{ + Registry = $registry + ActiveViewId = $InitialView + NavigationIndex = $navigationIndex + NavigationOffset = 0 + LastNavigationWidth = 25 + Focus = 'Navigation' + SelectedIndex = 0 + RowOffset = 0 + Filter = '' + Rows = $rows + RawResults = @{} + Notices = $notices + Metrics = [ordered]@{ + Coverage = $null + Critical = $null + Drift = $null + Health = $null + } + StatusMessage = 'Ready. Connect a tenant or open a workspace.' + StatusStyle = 'Muted' + Busy = $false + ExitRequested = $false + HitTargets = [Collections.Generic.List[object]]::new() + Terminal = $null + Settings = [ordered]@{ + BaselinePath = '' + OutputDirectory = (Get-Location).Path + SnapshotPath = '' + ScopeTagFilter = '' + } + } +} + +function Set-IACTuiStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][AllowEmptyString()][string]$Message, + [ValidateSet('Muted', 'Blue', 'Success', 'Warning', 'Error')][string]$Style = 'Muted' + ) + + $State.StatusMessage = $Message + $State.StatusStyle = $Style +} + +function Set-IACTuiActiveView { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][string]$ViewId + ) + + $index = [array]::IndexOf(@($State.Registry.Id), $ViewId) + if ($index -lt 0) { return } + $State.ActiveViewId = $ViewId + $State.NavigationIndex = $index + $State.SelectedIndex = 0 + $State.RowOffset = 0 + $State.Filter = '' +} + +function Get-IACTuiObjectProperty { + [CmdletBinding()] + param( + [AllowNull()]$InputObject, + [Parameter(Mandatory)][string[]]$Name + ) + + if ($null -eq $InputObject) { return $null } + foreach ($candidate in $Name) { + $property = $InputObject.PSObject.Properties[$candidate] + if ($property -and $null -ne $property.Value -and "$($property.Value)" -ne '') { return $property.Value } + } + return $null +} + +function ConvertTo-IACTuiRow { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)]$InputObject, + [int]$Ordinal = 0 + ) + + process { + if ($InputObject -is [System.Management.Automation.InformationRecord]) { return } + if ($InputObject -is [System.Management.Automation.WarningRecord]) { + return [PSCustomObject][ordered]@{ + Title = 'Warning' + Status = 'Warning' + Summary = "$InputObject" + DetailLines = @("$InputObject") + Raw = $InputObject + } + } + $title = Get-IACTuiObjectProperty -InputObject $InputObject -Name @( + 'PolicyName', 'DisplayName', 'displayName', 'Name', 'name', 'Title', 'RuleId', + 'Check', 'TenantName', 'UserPrincipalName', 'DeviceName', 'FilterName', 'Id', 'id' + ) + if (-not $title) { $title = "Result $($Ordinal + 1)" } + $status = Get-IACTuiObjectProperty -InputObject $InputObject -Name @( + 'Severity', 'Risk', 'Status', 'Result', 'EffectiveState', 'ChangeType', 'AssignmentMode', 'Type' + ) + if (-not $status) { $status = 'Available' } + $summary = Get-IACTuiObjectProperty -InputObject $InputObject -Name @( + 'Message', 'Summary', 'Detail', 'Description', 'AssignmentReason', 'Reason', 'Remediation' + ) + + $detail = [Collections.Generic.List[string]]::new() + foreach ($property in @($InputObject.PSObject.Properties)) { + if ($detail.Count -ge 12) { break } + if ($property.Name -match '^PS' -or $null -eq $property.Value) { continue } + if ($property.Value -is [Collections.IDictionary] -or + ($property.Value -is [Collections.IEnumerable] -and $property.Value -isnot [string])) { continue } + $value = "$($property.Value)" -replace '[\r\n\t]+', ' ' + if ($value.Length -gt 180) { $value = $value.Substring(0, 179) + '…' } + [void]$detail.Add("$($property.Name): $value") + } + if ($detail.Count -eq 0 -and $summary) { [void]$detail.Add("$summary") } + [PSCustomObject][ordered]@{ + Title = "$title" + Status = "$status" + Summary = "$summary" + DetailLines = @($detail) + Raw = $InputObject + } + } +} + +function ConvertTo-IACTuiRows { + [CmdletBinding()] + param([AllowNull()][object[]]$InputObject) + + $rows = [Collections.Generic.List[object]]::new() + $ordinal = 0 + foreach ($item in @($InputObject)) { + if ($null -eq $item -or $item -is [System.Management.Automation.InformationRecord]) { continue } + $row = ConvertTo-IACTuiRow -InputObject $item -Ordinal $ordinal + if ($row) { [void]$rows.Add($row); $ordinal++ } + } + return @($rows) +} + +function Get-IACTuiViewRows { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [string]$ViewId = $State.ActiveViewId + ) + + $rows = @($State.Rows[$ViewId]) + if ([string]::IsNullOrWhiteSpace($State.Filter)) { return $rows } + return @($rows | Where-Object { + $_.Title -like "*$($State.Filter)*" -or $_.Status -like "*$($State.Filter)*" -or + $_.Summary -like "*$($State.Filter)*" -or (@($_.DetailLines) -join ' ') -like "*$($State.Filter)*" + }) +} + +function Set-IACTuiResult { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][string]$ViewId, + [AllowNull()][object[]]$Result, + [Parameter(Mandatory)][string]$SuccessMessage + ) + + $State.RawResults[$ViewId] = @($Result) + $State.Rows[$ViewId] = @(ConvertTo-IACTuiRows -InputObject @($Result)) + $State.SelectedIndex = 0 + $State.RowOffset = 0 + $State.Filter = '' + Set-IACTuiStatus -State $State -Message $SuccessMessage -Style Success +} + +function Test-IACTuiConnected { + [CmdletBinding()] + param([Parameter(Mandatory)]$State) + + if ($script:GraphEndpoint) { return $true } + Set-IACTuiActiveView -State $State -ViewId Settings + Set-IACTuiStatus -State $State -Message 'Connect a tenant before loading live data.' -Style Warning + return $false +} + +function Show-IACTuiModalFrame { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string[]]$Lines, + [string[]]$Choices = @(), + [int]$SelectedChoice = -1, + [AllowEmptyString()][string]$InputText = '', + [switch]$ShowInput + ) + + $width = [math]::Max(1, [math]::Min(1000, $(try { [Console]::WindowWidth } catch { 120 }))) + $height = [math]::Max(1, [math]::Min(500, $(try { [Console]::WindowHeight } catch { 36 }))) + $buffer = New-IACTuiBuffer -Width $width -Height $height + Write-IACTuiBufferFill -Buffer $buffer -X 0 -Y 0 -Width $width -Height $height -Style Canvas + if ($width -lt 20 -or $height -lt 8) { + Write-IACTuiBufferText -Buffer $buffer -X 0 -Y 0 -Text $Title -Style Accent -MaxWidth $width + if ($height -gt 2) { Write-IACTuiBufferText -Buffer $buffer -X 0 -Y 2 -Text $Lines[0] -Style Muted -MaxWidth $width } + if ($ShowInput -and $height -gt 4) { Write-IACTuiBufferText -Buffer $buffer -X 0 -Y 4 -Text $InputText -Style Text -MaxWidth $width } + $ansi = [bool]($State.Terminal -and $State.Terminal.ColorEnabled) + $frame = ConvertTo-IACTuiBufferText -Buffer $buffer -Ansi:$ansi + if ($State.Terminal -and $State.Terminal.VirtualTerminal) { [Console]::Write("`e[H$frame") } else { Clear-Host; [Console]::Write($frame) } + return + } + $boxWidth = [math]::Min($width - 4, [math]::Max(54, [math]::Floor($width * 0.62))) + $contentRows = $Lines.Count + $Choices.Count + $(if ($ShowInput) { 3 } else { 1 }) + $boxHeight = [math]::Min($height - 4, [math]::Max(10, $contentRows + 6)) + $boxX = [math]::Floor(($width - $boxWidth) / 2) + $boxY = [math]::Floor(($height - $boxHeight) / 2) + Write-IACTuiBox -Buffer $buffer -X $boxX -Y $boxY -Width $boxWidth -Height $boxHeight -Style PanelRaised -BorderStyle Accent + Write-IACTuiBufferText -Buffer $buffer -X ($boxX + 3) -Y ($boxY + 1) -Text $Title -Style Accent -MaxWidth ($boxWidth - 6) + $row = $boxY + 3 + foreach ($line in $Lines) { + Write-IACTuiBufferText -Buffer $buffer -X ($boxX + 3) -Y $row -Text $line -Style Muted -MaxWidth ($boxWidth - 6) + $row++ + } + if ($ShowInput) { + $row++ + Write-IACTuiBufferFill -Buffer $buffer -X ($boxX + 3) -Y $row -Width ($boxWidth - 6) -Height 1 -Style Panel + $displayInput = ConvertTo-IACTuiDisplayText -Value "$InputText▌" -Width ($boxWidth - 8) + Write-IACTuiBufferText -Buffer $buffer -X ($boxX + 4) -Y $row -Text $displayInput -Style Text + $row += 2 + } + foreach ($choice in $Choices) { + $choiceIndex = [array]::IndexOf($Choices, $choice) + $choiceStyle = if ($choiceIndex -eq $SelectedChoice) { 'AccentStrong' } else { 'Selected' } + $label = " $choice " + Write-IACTuiBufferText -Buffer $buffer -X ($boxX + 3) -Y $row -Text $label -Style $choiceStyle + Add-IACTuiHitTarget -State $State -X ($boxX + 3) -Y $row -Width $label.Length -Height 1 -Action ModalChoice -Value $choiceIndex + $row++ + } + Write-IACTuiBufferText -Buffer $buffer -X ($boxX + 3) -Y ($boxY + $boxHeight - 2) -Text 'Enter confirm Esc cancel' -Style Muted -MaxWidth ($boxWidth - 6) + $ansi = [bool]($State.Terminal -and $State.Terminal.ColorEnabled) + $frame = ConvertTo-IACTuiBufferText -Buffer $buffer -Ansi:$ansi + if ($State.Terminal -and $State.Terminal.VirtualTerminal) { [Console]::Write("`e[H$frame") } else { Clear-Host; [Console]::Write($frame) } +} + +function Read-IACTuiTextInput { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Prompt, + [AllowEmptyString()][string]$DefaultValue = '', + [switch]$Required + ) + + $value = "$DefaultValue" + while ($true) { + $State.HitTargets.Clear() + $guidance = if ($Required) { @($Prompt, 'A value is required.') } else { @($Prompt, 'Leave blank to skip.') } + Show-IACTuiModalFrame -State $State -Title $Title -Lines $guidance -InputText $value -ShowInput -Choices @('Confirm', 'Cancel') -SelectedChoice 0 + $inputEvent = Read-IACTuiInput + if (Test-IACTuiControlCEvent -InputEvent $inputEvent) { $State.ExitRequested = $true; return $null } + if ($inputEvent.Kind -eq 'Mouse') { + if ($inputEvent.Action -eq 'Wheel') { continue } + if ($inputEvent.Button -eq 'Left' -and $inputEvent.Action -eq 'Down') { + $target = Get-IACTuiHitTarget -State $State -X $inputEvent.X -Y $inputEvent.Y + if ($target -and $target.Action -eq 'ModalChoice') { + if ([int]$target.Value -eq 1) { return $null } + if (-not $Required -or -not [string]::IsNullOrWhiteSpace($value)) { return $value } + } + } + continue + } + switch ($inputEvent.Key) { + 'Escape' { return $null } + 'Enter' { + if (-not $Required -or -not [string]::IsNullOrWhiteSpace($value)) { return $value } + } + 'Backspace' { if ($value.Length -gt 0) { $value = $value.Substring(0, $value.Length - 1) } } + default { + if ($inputEvent.Character.Length -eq 1 -and -not [char]::IsControl($inputEvent.Character[0])) { $value += $inputEvent.Character } + } + } + } +} + +function Read-IACTuiChoice { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Prompt, + [Parameter(Mandatory)][ValidateCount(1, 20)][string[]]$Choice, + [int]$DefaultIndex = 0 + ) + + $selected = [math]::Max(0, [math]::Min($DefaultIndex, $Choice.Count - 1)) + while ($true) { + $State.HitTargets.Clear() + Show-IACTuiModalFrame -State $State -Title $Title -Lines @($Prompt) -Choices $Choice -SelectedChoice $selected + $inputEvent = Read-IACTuiInput + if (Test-IACTuiControlCEvent -InputEvent $inputEvent) { $State.ExitRequested = $true; return $null } + if ($inputEvent.Kind -eq 'Mouse') { + if ($inputEvent.Action -eq 'Wheel') { + $selected = [math]::Max(0, [math]::Min($Choice.Count - 1, $selected - $inputEvent.WheelDelta)) + continue + } + if ($inputEvent.Button -eq 'Left' -and $inputEvent.Action -eq 'Down') { + $target = Get-IACTuiHitTarget -State $State -X $inputEvent.X -Y $inputEvent.Y + if ($target -and $target.Action -eq 'ModalChoice') { return $Choice[[int]$target.Value] } + } + continue + } + switch ($inputEvent.Key) { + 'Escape' { return $null } + 'Enter' { return $Choice[$selected] } + 'UpArrow' { $selected = [math]::Max(0, $selected - 1) } + 'DownArrow' { $selected = [math]::Min($Choice.Count - 1, $selected + 1) } + default { + if ($inputEvent.Character -in @('k', 'K')) { $selected = [math]::Max(0, $selected - 1) } + elseif ($inputEvent.Character -in @('j', 'J')) { $selected = [math]::Min($Choice.Count - 1, $selected + 1) } + } + } + } +} + +function Read-IACTuiSecretInput { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Prompt + ) + + $secret = [Security.SecureString]::new() + while ($true) { + $State.HitTargets.Clear() + Show-IACTuiModalFrame -State $State -Title $Title -Lines @($Prompt, 'The value is kept as a SecureString and is never displayed.') ` + -InputText ('•' * $secret.Length) -ShowInput -Choices @('Confirm', 'Cancel') -SelectedChoice 0 + $inputEvent = Read-IACTuiInput + if (Test-IACTuiControlCEvent -InputEvent $inputEvent) { $State.ExitRequested = $true; $secret.Dispose(); return $null } + if ($inputEvent.Kind -eq 'Mouse') { + if ($inputEvent.Button -eq 'Left' -and $inputEvent.Action -eq 'Down') { + $target = Get-IACTuiHitTarget -State $State -X $inputEvent.X -Y $inputEvent.Y + if ($target -and $target.Action -eq 'ModalChoice') { + if ([int]$target.Value -eq 1) { $secret.Dispose(); return $null } + if ($secret.Length -gt 0) { $secret.MakeReadOnly(); return $secret } + } + } + continue + } + if ($inputEvent.Kind -ne 'Key') { continue } + switch ($inputEvent.Key) { + 'Escape' { $secret.Dispose(); return $null } + 'Enter' { if ($secret.Length -gt 0) { $secret.MakeReadOnly(); return $secret } } + 'Backspace' { if ($secret.Length -gt 0) { $secret.RemoveAt($secret.Length - 1) } } + default { + if ($inputEvent.Character.Length -eq 1 -and -not [char]::IsControl($inputEvent.Character[0])) { + $secret.AppendChar($inputEvent.Character[0]) + } + } + } + } +} + +function Read-IACTuiConfirmation { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Prompt + ) + + (Read-IACTuiChoice -State $State -Title $Title -Prompt $Prompt -Choice @('Continue', 'Cancel') -DefaultIndex 1) -eq 'Continue' +} + +function Read-IACTuiMultiChoice { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][string]$Title, + [Parameter(Mandatory)][string]$Prompt, + [Parameter(Mandatory)][ValidateCount(1, 16)][string[]]$Choice, + [string[]]$DefaultChoice = @(), + [scriptblock]$InputProvider, + [switch]$SuppressRender + ) + + $selected = [Collections.Generic.HashSet[int]]::new() + foreach ($defaultValue in $DefaultChoice) { + $index = [array]::IndexOf($Choice, $defaultValue) + if ($index -ge 0) { $null = $selected.Add($index) } + } + $cursor = 0 + :multiChoiceLoop while ($true) { + $displayChoices = @( + for ($index = 0; $index -lt $Choice.Count; $index++) { + "[$(if ($selected.Contains($index)) { 'x' } else { ' ' })] $($Choice[$index])" + } + 'Apply selection' + 'Cancel' + ) + if (-not $SuppressRender) { + $State.HitTargets.Clear() + Show-IACTuiModalFrame -State $State -Title $Title -Lines @($Prompt, 'Select one or more profiles. Space or click toggles a profile.') ` + -Choices $displayChoices -SelectedChoice $cursor + } + $inputEvent = if ($InputProvider) { & $InputProvider } else { Read-IACTuiInput } + if (Test-IACTuiControlCEvent -InputEvent $inputEvent) { $State.ExitRequested = $true; return $null } + $chosenIndex = -1 + if ($inputEvent.Kind -eq 'Mouse') { + if ($inputEvent.Action -eq 'Wheel') { + $cursor = [math]::Max(0, [math]::Min($displayChoices.Count - 1, $cursor - $inputEvent.WheelDelta)) + continue + } + if ($inputEvent.Button -eq 'Left' -and $inputEvent.Action -eq 'Down') { + $target = Get-IACTuiHitTarget -State $State -X $inputEvent.X -Y $inputEvent.Y + if ($target -and $target.Action -eq 'ModalChoice') { $chosenIndex = [int]$target.Value } + } + if ($chosenIndex -lt 0) { continue } + } + elseif ($inputEvent.Kind -eq 'Key') { + switch ($inputEvent.Key) { + 'Escape' { return $null } + 'UpArrow' { $cursor = [math]::Max(0, $cursor - 1); continue multiChoiceLoop } + 'DownArrow' { $cursor = [math]::Min($displayChoices.Count - 1, $cursor + 1); continue multiChoiceLoop } + 'Spacebar' { $chosenIndex = $cursor } + 'Enter' { $chosenIndex = $cursor } + default { + if ($inputEvent.Character -in @('k', 'K')) { $cursor = [math]::Max(0, $cursor - 1); continue multiChoiceLoop } + if ($inputEvent.Character -in @('j', 'J')) { $cursor = [math]::Min($displayChoices.Count - 1, $cursor + 1); continue multiChoiceLoop } + continue multiChoiceLoop + } + } + } + else { continue } + + if ($chosenIndex -eq $Choice.Count + 1) { return $null } + if ($chosenIndex -eq $Choice.Count) { + if ($selected.Count -eq 0) { + Set-IACTuiStatus -State $State -Message 'Select at least one capability profile.' -Style Warning + continue + } + return @($selected | Sort-Object | ForEach-Object { $Choice[$_] }) + } + + if ($Choice[$chosenIndex] -eq 'Full') { + $selected.Clear() + $null = $selected.Add($chosenIndex) + } + else { + $fullIndex = [array]::IndexOf($Choice, 'Full') + if ($fullIndex -ge 0) { $null = $selected.Remove($fullIndex) } + if (-not $selected.Remove($chosenIndex)) { $null = $selected.Add($chosenIndex) } + } + $cursor = $chosenIndex + } +} + +function Invoke-IACTuiCapturedOperation { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][string]$ViewId, + [Parameter(Mandatory)][scriptblock]$Operation, + [Parameter(Mandatory)][string]$SuccessMessage, + [switch]$SuppressRender + ) + + $State.Busy = $true + if (-not $SuppressRender) { Show-IACTuiFrame -State $State } + $restoreControlCMode = $false + $originalControlCMode = $false + try { + try { + $originalControlCMode = [Console]::TreatControlCAsInput + [Console]::TreatControlCAsInput = $false + $restoreControlCMode = $true + } + catch { } + $result = @(& $Operation *>&1) + $operationErrors = @($result | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) + $operationWarnings = @($result | Where-Object { $_ -is [System.Management.Automation.WarningRecord] }) + $objects = @($result | Where-Object { + $_ -isnot [System.Management.Automation.ErrorRecord] -and + $_ -isnot [System.Management.Automation.WarningRecord] -and + $_ -isnot [System.Management.Automation.InformationRecord] -and + $_ -isnot [System.Management.Automation.VerboseRecord] -and + $_ -isnot [System.Management.Automation.DebugRecord] + }) + $State.Notices[$ViewId] = @( + $operationErrors | ForEach-Object { [PSCustomObject]@{ Status = 'Error'; Message = $_.Exception.Message } } + $operationWarnings | ForEach-Object { [PSCustomObject]@{ Status = 'Warning'; Message = "$($_.Message)" } } + ) + if ($objects.Count -gt 0) { + Set-IACTuiResult -State $State -ViewId $ViewId -Result $objects -SuccessMessage $SuccessMessage + if ($operationErrors.Count -gt 0) { + Set-IACTuiStatus -State $State -Message "Loaded $($objects.Count) results with $($operationErrors.Count) partial error(s)." -Style Warning + } + elseif ($operationWarnings.Count -gt 0) { + Set-IACTuiStatus -State $State -Message "Loaded $($objects.Count) results with $($operationWarnings.Count) warning(s)." -Style Warning + } + return $objects + } + if ($operationErrors.Count -gt 0) { + $errorRows = @($operationErrors | ForEach-Object { + [PSCustomObject][ordered]@{ Title = 'Operation failed'; Status = 'Error'; Message = $_.Exception.Message } + }) + Set-IACTuiResult -State $State -ViewId $ViewId -Result $errorRows -SuccessMessage $operationErrors[0].Exception.Message + Set-IACTuiStatus -State $State -Message $operationErrors[0].Exception.Message -Style Error + return @() + } + if ($operationWarnings.Count -gt 0) { + $warningSummary = [PSCustomObject][ordered]@{ + Title = "Completed with $($operationWarnings.Count) warning(s)" + Status = 'Warning' + Message = "$($operationWarnings[0].Message)" + } + Set-IACTuiResult -State $State -ViewId $ViewId -Result @($warningSummary) -SuccessMessage $warningSummary.Title + Set-IACTuiStatus -State $State -Message $warningSummary.Title -Style Warning + return @() + } + if ($objects.Count -eq 0) { + $activityMessages = @($result | Where-Object { $_ -is [System.Management.Automation.InformationRecord] } | + ForEach-Object { "$($_.MessageData)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $objects = @($activityMessages | ForEach-Object { + $status = Get-IACTuiInformationStatus -Message $_ + [PSCustomObject][ordered]@{ + Title = (ConvertTo-IACTuiDisplayText -Value $_ -Width 80) + Status = $status + Message = $_ + } + }) + } + Set-IACTuiResult -State $State -ViewId $ViewId -Result $objects -SuccessMessage $SuccessMessage + $State.RawResults[$ViewId] = @() + $informationErrors = @($objects | Where-Object Status -EQ Error) + if ($informationErrors.Count -gt 0) { + $State.Notices[$ViewId] = @($informationErrors | ForEach-Object { + [PSCustomObject]@{ Status = 'Error'; Message = $_.Message } + }) + Set-IACTuiStatus -State $State -Message $informationErrors[0].Message -Style Error + return @() + } + $informationWarnings = @($objects | Where-Object Status -EQ Warning) + if ($informationWarnings.Count -gt 0) { + $State.Notices[$ViewId] = @($informationWarnings | ForEach-Object { + [PSCustomObject]@{ Status = 'Warning'; Message = $_.Message } + }) + Set-IACTuiStatus -State $State -Message $informationWarnings[0].Message -Style Warning + return @() + } + return @() + } + catch { + $errorRow = [PSCustomObject][ordered]@{ + Title = 'Operation failed' + Status = 'Error' + Message = $_.Exception.Message + } + Set-IACTuiResult -State $State -ViewId $ViewId -Result @($errorRow) -SuccessMessage $_.Exception.Message + $State.Notices[$ViewId] = @([PSCustomObject]@{ Status = 'Error'; Message = $_.Exception.Message }) + Set-IACTuiStatus -State $State -Message $_.Exception.Message -Style Error + return @() + } + finally { + if ($restoreControlCMode) { + try { [Console]::TreatControlCAsInput = $originalControlCMode } + catch { } + } + $State.Busy = $false + } +} + +function Get-IACTuiInformationStatus { + [CmdletBinding()] + param([Parameter(Mandatory)][AllowEmptyString()][string]$Message) + + $normalized = $Message.Trim() + if ($normalized -match '(?i)^(not connected\.|invalid |user not found:|device not found:|multiple (devices|groups) (match|found)|the group lookup .+ invalid|error (fetching|checking)|setting definitions file (not found|is empty)|please provide at least two groups|both .+ required|no (user or device|group|keyword|valid upns?|device name|upn) provided|no (group|device) found)') { + return 'Error' + } + if ($normalized -match '(?i)(nothing to simulate|already a member)') { return 'Warning' } + return 'Complete' +} + +function Export-IACTuiScanRunSnapshot { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Run, + [Parameter(Mandatory)][string]$Path, + [switch]$Force + ) + + $coverageErrors = @($Run.Errors) + @($Run.Skipped) + $snapshotParameters = @{ + Path = $Path + InputObject = @($Run.Records) + CoverageCategory = @($Run.Selected) + CoverageComplete = [bool]$Run.Complete + CoverageError = @($coverageErrors) + Force = $Force + PassThru = $true + } + Export-IntuneAssignmentSnapshot @snapshotParameters +} + +function Invoke-IACTuiAssignmentAction { + [CmdletBinding()] + param([Parameter(Mandatory)]$State, [Parameter(Mandatory)][string]$ActionId) + + if (-not (Test-IACTuiConnected -State $State)) { return } + $scopeTag = $State.Settings.ScopeTagFilter + switch ($ActionId) { + 'SearchAssignments' { + $term = Read-IACTuiTextInput -State $State -Title 'Search policies' -Prompt 'Policy or application name' -Required + if ($null -ne $term) { Invoke-IACTuiCapturedOperation -State $State -ViewId Assignments -SuccessMessage 'Policy search complete.' -Operation { Search-IntunePolicy -PolicySearchTerm $term -PassThru } | Out-Null } + } + 'FindUserAssignments' { + $users = Read-IACTuiTextInput -State $State -Title 'Find a user' -Prompt 'User principal name (separate several with commas)' -Required + if ($null -ne $users) { Invoke-IACTuiCapturedOperation -State $State -ViewId Assignments -SuccessMessage 'User assignments loaded.' -Operation { Get-IntuneUserAssignment -UserPrincipalNames $users -ScopeTagFilter $scopeTag -PassThru } | Out-Null } + } + 'FindDeviceAssignments' { + $devices = Read-IACTuiTextInput -State $State -Title 'Find a device' -Prompt 'Device name or object ID (separate several with commas)' -Required + if ($null -ne $devices) { Invoke-IACTuiCapturedOperation -State $State -ViewId Assignments -SuccessMessage 'Device assignments loaded.' -Operation { Get-IntuneDeviceAssignment -DeviceNames $devices -ScopeTagFilter $scopeTag -PassThru } | Out-Null } + } + 'FindGroupAssignments' { + $groups = Read-IACTuiTextInput -State $State -Title 'Find a group' -Prompt 'Group name or object ID (separate several with commas)' -Required + if ($null -ne $groups) { Invoke-IACTuiCapturedOperation -State $State -ViewId Assignments -SuccessMessage 'Group assignments loaded.' -Operation { Get-IntuneGroupAssignment -GroupNames $groups -IncludeNestedGroups -ScopeTagFilter $scopeTag -PassThru } | Out-Null } + } + 'ExplainEffectiveAssignment' { + $user = Read-IACTuiTextInput -State $State -Title 'Explain effective state' -Prompt 'User principal name (optional)' + if ($null -eq $user) { return } + $device = Read-IACTuiTextInput -State $State -Title 'Explain effective state' -Prompt 'Device name or object ID (optional)' + if ($null -eq $device) { return } + if (-not $user -and -not $device) { Set-IACTuiStatus -State $State -Message 'Enter a user, a device, or both.' -Style Warning; return } + $explanationMode = 'Effective targeting trace' + if ($user -and $device) { + $explanationMode = Read-IACTuiChoice -State $State -Title 'Explain effective state' -Prompt 'Choose the result view.' -Choice @('Effective targeting trace', 'Combined user/device inventory') + if (-not $explanationMode) { return } + } + if ($explanationMode -eq 'Combined user/device inventory') { + Invoke-IACTuiCapturedOperation -State $State -ViewId Assignments -SuccessMessage 'Combined assignment inventory loaded.' -Operation { + Get-IntuneUserDeviceAssignment -UserPrincipalName $user -DeviceName $device -ScopeTagFilter $scopeTag -PassThru + } | Out-Null + } + else { + Invoke-IACTuiCapturedOperation -State $State -ViewId Assignments -SuccessMessage 'Effective assignment calculated.' -Operation { + Get-IntuneEffectiveAssignment -UserPrincipalName $user -DeviceName $device -PassThru + } | Out-Null + } + } + 'BrowseAssignmentInventory' { + $mode = Read-IACTuiChoice -State $State -Title 'Browse assignment inventory' -Prompt 'Choose the assignment view.' -Choice @( + 'All policies', 'All Users targeting', 'All Devices targeting', 'Unassigned policies', 'Empty target groups' + ) + if (-not $mode) { return } + $operation = switch ($mode) { + 'All policies' { { Get-IntuneAllPolicies -ScopeTagFilter $scopeTag -PassThru } } + 'All Users targeting' { { Get-IntuneAllUsersAssignment -ScopeTagFilter $scopeTag -PassThru } } + 'All Devices targeting' { { Get-IntuneAllDevicesAssignment -ScopeTagFilter $scopeTag -PassThru } } + 'Unassigned policies' { { Get-IntuneUnassignedPolicy -ScopeTagFilter $scopeTag -PassThru } } + 'Empty target groups' { { Get-IntuneEmptyGroup -PassThru } } + } + Invoke-IACTuiCapturedOperation -State $State -ViewId Assignments -SuccessMessage "$mode loaded." -Operation $operation | Out-Null + } + 'CompareGroupTargeting' { + $groups = Read-IACTuiTextInput -State $State -Title 'Compare groups' -Prompt 'Two or more group names, separated with commas' -Required + if ($null -ne $groups) { Invoke-IACTuiCapturedOperation -State $State -ViewId Assignments -SuccessMessage 'Group comparison complete.' -Operation { Compare-IntuneGroupAssignment -CompareGroupNames $groups -IncludeNestedGroups -PassThru } | Out-Null } + } + 'SearchConfiguredSettings' { + $keyword = Read-IACTuiTextInput -State $State -Title 'Search configured settings' -Prompt 'Setting name or keyword' -Required + if ($null -ne $keyword) { Invoke-IACTuiCapturedOperation -State $State -ViewId Assignments -SuccessMessage 'Settings search complete.' -Operation { Search-IntuneSetting -Keyword $keyword -PassThru } | Out-Null } + } + } +} + +function Invoke-IACTuiSimulatorAction { + [CmdletBinding()] + param([Parameter(Mandatory)]$State, [Parameter(Mandatory)][string]$ActionId) + + switch ($ActionId) { + 'SimulateAssignmentChange' { + $snapshot = Read-IACTuiTextInput -State $State -Title 'Propose assignment change' -Prompt 'Saved snapshot path' -DefaultValue $State.Settings.SnapshotPath -Required + if ($null -eq $snapshot) { return } + $change = Read-IACTuiChoice -State $State -Title 'Propose assignment change' -Prompt 'What should change?' -Choice @('Add assignment', 'Remove assignment', 'Replace target', 'Change filter', 'Change intent') + if (-not $change) { return } + $policy = Read-IACTuiTextInput -State $State -Title 'Propose assignment change' -Prompt 'Policy ID' -Required + if ($null -eq $policy) { return } + $changeType = @{ + 'Add assignment' = 'AddAssignment'; 'Remove assignment' = 'RemoveAssignment'; 'Replace target' = 'ReplaceTarget' + 'Change filter' = 'ChangeFilter'; 'Change intent' = 'ChangeIntent' + }[$change] + $parameters = @{ SnapshotPath = $snapshot; ChangeType = $changeType; PolicyId = $policy } + if ($changeType -ne 'AddAssignment') { + $assignment = Read-IACTuiTextInput -State $State -Title 'Propose assignment change' -Prompt 'Existing assignment ID' -Required + if ($null -eq $assignment) { return }; $parameters.AssignmentId = $assignment + } + if ($changeType -in @('AddAssignment', 'ReplaceTarget')) { + $targetType = Read-IACTuiChoice -State $State -Title 'Target' -Prompt 'Choose the target type.' -Choice @('Group', 'All Users', 'All Devices') + if (-not $targetType) { return } + $parameters.TargetType = $targetType -replace ' ', '' + if ($targetType -eq 'Group') { + $targetId = Read-IACTuiTextInput -State $State -Title 'Target' -Prompt 'Group object ID' -Required + if ($null -eq $targetId) { return }; $parameters.TargetId = $targetId + } + } + if ($changeType -eq 'ChangeIntent') { + $intent = Read-IACTuiTextInput -State $State -Title 'Application intent' -Prompt 'New intent (for example required or available)' -Required + if ($null -eq $intent) { return }; $parameters.Intent = $intent + } + if ($changeType -eq 'ChangeFilter') { + $filterId = Read-IACTuiTextInput -State $State -Title 'Assignment filter' -Prompt 'Filter ID (blank removes the filter)' + if ($null -eq $filterId) { return }; $parameters.FilterId = $filterId + $filterMode = Read-IACTuiChoice -State $State -Title 'Assignment filter' -Prompt 'How should the filter apply?' -Choice @('include', 'exclude', 'none') + if (-not $filterMode) { return }; $parameters.FilterMode = $filterMode + } + $State.Settings.SnapshotPath = $snapshot + Invoke-IACTuiCapturedOperation -State $State -ViewId Simulator -SuccessMessage 'Blast-radius simulation complete. No changes were written.' -Operation { Test-IntuneAssignmentChange @parameters } | Out-Null + } + { $_ -in @('SimulateMembershipAdd', 'SimulateMembershipRemoval') } { + if (-not (Test-IACTuiConnected -State $State)) { return } + $entityType = Read-IACTuiChoice -State $State -Title 'Membership simulation' -Prompt 'Whose membership should be modeled?' -Choice @('User', 'Device') + if (-not $entityType) { return } + $entity = Read-IACTuiTextInput -State $State -Title 'Membership simulation' -Prompt $(if ($entityType -eq 'User') { 'User principal name' } else { 'Device name or object ID' }) -Required + if ($null -eq $entity) { return } + $group = Read-IACTuiTextInput -State $State -Title 'Membership simulation' -Prompt 'Target group name or object ID' -Required + if ($null -eq $group) { return } + $entityParameters = if ($entityType -eq 'User') { @{ UserPrincipalNames = $entity } } else { @{ DeviceNames = $entity } } + if ($State.Settings.ScopeTagFilter) { $entityParameters.ScopeTagFilter = $State.Settings.ScopeTagFilter } + if ($ActionId -eq 'SimulateMembershipAdd') { + Invoke-IACTuiCapturedOperation -State $State -ViewId Simulator -SuccessMessage 'Membership-add simulation complete. No changes were written.' -Operation { + Test-IntuneGroupMembership @entityParameters -SimulateTargetGroup $group -PassThru + } | Out-Null + } + else { + Invoke-IACTuiCapturedOperation -State $State -ViewId Simulator -SuccessMessage 'Membership-removal simulation complete. No changes were written.' -Operation { + Test-IntuneGroupRemoval @entityParameters -SimulateRemoveTargetGroup $group -PassThru + } | Out-Null + } + } + } +} + +function Invoke-IACTuiWorkflowAction { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)][string]$ActionId + ) + + switch ($State.ActiveViewId) { + 'Assignments' { Invoke-IACTuiAssignmentAction -State $State -ActionId $ActionId; return } + 'Simulator' { Invoke-IACTuiSimulatorAction -State $State -ActionId $ActionId; return } + } + + switch ($ActionId) { + 'RefreshOverview' { + if (-not (Test-IACTuiConnected -State $State)) { return } + $result = @(Invoke-IACTuiCapturedOperation -State $State -ViewId Overview -SuccessMessage 'Tenant posture refreshed.' -Operation { Test-IntuneAssignmentGovernance }) + if ($State.StatusStyle -eq 'Error' -or ($State.StatusStyle -eq 'Warning' -and $result.Count -eq 0)) { + $State.Metrics.Critical = $null + $State.Metrics.Coverage = $null + return + } + $critical = @($result | Where-Object Severity -in @('Critical', 'High')).Count + $coverageFinding = @($result | Where-Object RuleId -EQ 'IAG008').Count -gt 0 + $State.Metrics.Critical = $critical + $State.Metrics.Coverage = if ($coverageFinding) { 'Partial' } else { 'Complete' } + } + 'ResumeScan' { + if (-not (Test-IACTuiConnected -State $State)) { return } + $checkpoint = Read-IACTuiTextInput -State $State -Title 'Resume assignment scan' -Prompt 'Checkpoint path (blank starts a new scan)' -DefaultValue $State.Settings.SnapshotPath + if ($null -eq $checkpoint) { return } + $budgetText = Read-IACTuiTextInput -State $State -Title 'Resume assignment scan' -Prompt 'Time budget in seconds (0 means no limit)' -DefaultValue '0' -Required + if ($null -eq $budgetText) { return } + $budget = 0 + if (-not [int]::TryParse($budgetText, [ref]$budget) -or $budget -lt 0) { Set-IACTuiStatus -State $State -Message 'Time budget must be zero or a positive whole number.' -Style Warning; return } + $scanParameters = @{ ScanBudgetSeconds = $budget; ShowProgress = $true } + if ($checkpoint) { $scanParameters.CheckpointPath = $checkpoint; $scanParameters.Resume = $true } + Invoke-IACTuiCapturedOperation -State $State -ViewId Overview -SuccessMessage 'Assignment scan complete.' -Operation { Invoke-IntuneAssignmentScan @scanParameters } | Out-Null + } + 'RefreshGovernance' { + $source = Read-IACTuiChoice -State $State -Title 'Run governance scan' -Prompt 'Choose the source to evaluate.' -Choice @('Connected tenant', 'Saved snapshot') + if (-not $source) { return } + $rulePath = Read-IACTuiTextInput -State $State -Title 'Governance rules' -Prompt 'Custom rule pack path (blank uses the built-in baseline)' + if ($null -eq $rulePath) { return } + $waiverPath = Read-IACTuiTextInput -State $State -Title 'Governance waivers' -Prompt 'Approved waiver file (optional)' + if ($null -eq $waiverPath) { return } + $visibility = Read-IACTuiChoice -State $State -Title 'Governance waivers' -Prompt 'Which findings should be visible?' -Choice @('Active findings only', 'Include approved waivers') + if (-not $visibility) { return } + $governanceParameters = @{} + if ($rulePath) { $governanceParameters.RulePath = $rulePath } + if ($waiverPath) { $governanceParameters.WaiverPath = $waiverPath } + if ($visibility -eq 'Include approved waivers') { $governanceParameters.IncludeSuppressed = $true } + if ($source -eq 'Connected tenant') { + if (-not (Test-IACTuiConnected -State $State)) { return } + Invoke-IACTuiCapturedOperation -State $State -ViewId Governance -SuccessMessage 'Governance scan complete.' -Operation { Test-IntuneAssignmentGovernance @governanceParameters } | Out-Null + } + else { + $path = Read-IACTuiTextInput -State $State -Title 'Run governance scan' -Prompt 'Snapshot path' -DefaultValue $State.Settings.SnapshotPath -Required + if ($null -eq $path) { return } + $State.Settings.SnapshotPath = $path + $governanceParameters.SnapshotPath = $path + Invoke-IACTuiCapturedOperation -State $State -ViewId Governance -SuccessMessage 'Offline governance scan complete.' -Operation { Test-IntuneAssignmentGovernance @governanceParameters } | Out-Null + } + } + 'RefreshDrift' { + $baseline = Read-IACTuiTextInput -State $State -Title 'Compare approved baseline' -Prompt 'Approved baseline path' -DefaultValue $State.Settings.BaselinePath -Required + if ($null -eq $baseline) { return } + $current = Read-IACTuiTextInput -State $State -Title 'Compare approved baseline' -Prompt 'Current snapshot path (blank captures the connected tenant)' + if ($null -eq $current) { return } + if (-not $current -and -not (Test-IACTuiConnected -State $State)) { return } + $State.Settings.BaselinePath = $baseline + $parameters = @{ BaselinePath = $baseline; IncludeAuditAttribution = $true } + if ($current) { $parameters.CurrentSnapshotPath = $current } + $result = @(Invoke-IACTuiCapturedOperation -State $State -ViewId Drift -SuccessMessage 'Drift comparison complete.' -Operation { Get-IntuneAssignmentDrift @parameters }) + $State.Metrics.Drift = if ($State.StatusStyle -eq 'Error' -or ($State.StatusStyle -eq 'Warning' -and $result.Count -eq 0)) { $null } else { $result.Count } + } + 'ApproveBaseline' { + $baseline = Read-IACTuiTextInput -State $State -Title 'Approve drift baseline' -Prompt 'Approved baseline destination' -DefaultValue $State.Settings.BaselinePath -Required + if ($null -eq $baseline) { return } + $current = Read-IACTuiTextInput -State $State -Title 'Approve drift baseline' -Prompt 'Reviewed snapshot path' -DefaultValue $State.Settings.SnapshotPath -Required + if ($null -eq $current) { return } + if (-not (Read-IACTuiConfirmation -State $State -Title 'Approve drift baseline' -Prompt 'Replace the approved baseline with this reviewed snapshot?')) { return } + $State.Settings.BaselinePath = $baseline + Invoke-IACTuiCapturedOperation -State $State -ViewId Drift -SuccessMessage 'Approved baseline updated.' -Operation { + Get-IntuneAssignmentDrift -BaselinePath $baseline -CurrentSnapshotPath $current -ApproveBaseline -Force + } | Out-Null + } + 'CaptureSnapshot' { + if (-not (Test-IACTuiConnected -State $State)) { return } + $path = Read-IACTuiTextInput -State $State -Title 'Capture snapshot' -Prompt 'Destination JSON path' -DefaultValue $State.Settings.SnapshotPath -Required + if ($null -eq $path) { return } + $force = $false + if (Test-Path -LiteralPath $path -PathType Leaf) { + if (-not (Read-IACTuiConfirmation -State $State -Title 'Capture snapshot' -Prompt 'A snapshot already exists at this path. Replace it?')) { return } + $force = $true + } + $State.Settings.SnapshotPath = $path + Invoke-IACTuiCapturedOperation -State $State -ViewId Drift -SuccessMessage "Snapshot saved to $path." -Operation { + $run = Invoke-IntuneAssignmentScan -ShowProgress + Export-IACTuiScanRunSnapshot -Run $run -Path $path -Force:$force + } | Out-Null + } + 'CompareSnapshots' { + $reference = Read-IACTuiTextInput -State $State -Title 'Compare snapshots' -Prompt 'Reference snapshot path' -DefaultValue $State.Settings.BaselinePath -Required + if ($null -eq $reference) { return } + $difference = Read-IACTuiTextInput -State $State -Title 'Compare snapshots' -Prompt 'Newer snapshot path' -DefaultValue $State.Settings.SnapshotPath -Required + if ($null -eq $difference) { return } + Invoke-IACTuiCapturedOperation -State $State -ViewId Drift -SuccessMessage 'Snapshot comparison complete.' -Operation { + Compare-IntuneAssignmentSnapshot -ReferencePath $reference -DifferencePath $difference + } | Out-Null + } + 'RefreshHealth' { + if (-not (Test-IACTuiConnected -State $State)) { return } + $workload = Read-IACTuiChoice -State $State -Title 'Load delivery health' -Prompt 'Choose a workload.' -Choice @('All workloads', 'Device configuration', 'Compliance', 'Applications') + if (-not $workload) { return } + $staleText = Read-IACTuiTextInput -State $State -Title 'Load delivery health' -Prompt 'Mark reports stale after this many days' -DefaultValue '14' -Required + if ($null -eq $staleText) { return } + $staleDays = 0 + if (-not [int]::TryParse($staleText, [ref]$staleDays) -or $staleDays -lt 1 -or $staleDays -gt 3650) { + Set-IACTuiStatus -State $State -Message 'Stale-report age must be between 1 and 3650 days.' -Style Warning + return + } + $workloads = switch ($workload) { 'All workloads' { @('DeviceConfiguration', 'Compliance', 'Applications') }; 'Device configuration' { @('DeviceConfiguration') }; default { @($workload) } } + $result = @(Invoke-IACTuiCapturedOperation -State $State -ViewId Health -SuccessMessage 'Delivery health loaded.' -Operation { Get-IntuneAssignmentHealth -Workload $workloads -StaleAfterDays $staleDays }) + $success = @($result | Where-Object Status -in @('SuccessfullyApplied', 'Installed', 'Success')).Count + $State.Metrics.Health = if ($result.Count -gt 0) { '{0:P0}' -f ($success / $result.Count) } else { '—' } + } + 'LoadFailures' { + if (-not (Test-IACTuiConnected -State $State)) { return } + Invoke-IACTuiCapturedOperation -State $State -ViewId Health -SuccessMessage 'Assignment failures loaded.' -Operation { Get-IntuneFailedAssignment -PassThru } | Out-Null + } + 'RefreshAccess' { + $snapshot = Read-IACTuiTextInput -State $State -Title 'Analyze administrative access' -Prompt 'Snapshot path (blank uses the connected tenant)' + if ($null -eq $snapshot) { return } + if (-not $snapshot -and -not (Test-IACTuiConnected -State $State)) { return } + $policy = Read-IACTuiTextInput -State $State -Title 'Analyze administrative access' -Prompt 'Policy ID to narrow results (optional)' + if ($null -eq $policy) { return } + $parameters = @{}; if ($snapshot) { $parameters.SnapshotPath = $snapshot }; if ($policy) { $parameters.PolicyId = @($policy) } + Invoke-IACTuiCapturedOperation -State $State -ViewId Access -SuccessMessage 'Administrative access analysis complete.' -Operation { Get-IntuneAssignmentAccess @parameters } | Out-Null + } + 'RefreshFilters' { + $snapshot = Read-IACTuiTextInput -State $State -Title 'Audit assignment filters' -Prompt 'Snapshot path (blank scans the connected tenant)' + if ($null -eq $snapshot) { return } + if (-not $snapshot -and -not (Test-IACTuiConnected -State $State)) { return } + $parameters = @{}; if ($snapshot) { $parameters.SnapshotPath = $snapshot } + Invoke-IACTuiCapturedOperation -State $State -ViewId Filters -SuccessMessage 'Assignment-filter audit complete.' -Operation { Test-IntuneAssignmentFilterSet @parameters } | Out-Null + } + 'EvaluateFilter' { + if (-not (Test-IACTuiConnected -State $State)) { return } + $device = Read-IACTuiTextInput -State $State -Title 'Evaluate a filter' -Prompt 'Managed device name' -Required + if ($null -eq $device) { return } + $filter = Read-IACTuiTextInput -State $State -Title 'Evaluate a filter' -Prompt 'Assignment filter ID' -Required + if ($null -eq $filter) { return } + Invoke-IACTuiCapturedOperation -State $State -ViewId Filters -SuccessMessage 'Device evaluation complete.' -Operation { Test-IntuneAssignmentFilter -DeviceName $device -FilterId $filter } | Out-Null + } + 'RunFleetScan' { + $configuration = Read-IACTuiTextInput -State $State -Title 'Scan tenant fleet' -Prompt 'Fleet configuration path' -Required + if ($null -eq $configuration) { return } + $output = Read-IACTuiTextInput -State $State -Title 'Scan tenant fleet' -Prompt 'Output directory' -DefaultValue $State.Settings.OutputDirectory -Required + if ($null -eq $output) { return } + $State.Settings.OutputDirectory = $output + Invoke-IACTuiCapturedOperation -State $State -ViewId Fleet -SuccessMessage 'Fleet scan finished.' -Operation { + Invoke-IntuneAssignmentFleetScan -ConfigurationPath $configuration -OutputDirectory $output + } | Out-Null + } + 'CreateHtmlReport' { + if (-not (Test-IACTuiConnected -State $State)) { return } + $path = Read-IACTuiTextInput -State $State -Title 'Create HTML report' -Prompt 'HTML file or destination directory (blank uses Documents)' + if ($null -eq $path) { return } + $companion = Read-IACTuiChoice -State $State -Title 'Create HTML report' -Prompt 'Create a CSV companion for data analysis?' -Choice @('HTML and CSV', 'HTML only') + if (-not $companion) { return } + $reportParameters = @{} + if ($path) { $reportParameters.HTMLReportPath = $path } + if ($companion -eq 'HTML only') { $reportParameters.NoCSVReport = $true } + else { + $csvPath = Read-IACTuiTextInput -State $State -Title 'Create HTML report' -Prompt 'Custom CSV path (blank follows the HTML file)' + if ($null -eq $csvPath) { return } + if ($csvPath) { $reportParameters.CSVReportPath = $csvPath } + } + Invoke-IACTuiCapturedOperation -State $State -ViewId Reports -SuccessMessage 'HTML report created.' -Operation { + New-IntuneHTMLReport @reportParameters + } | Out-Null + } + 'MigrateRecords' { + $source = Read-IACTuiTextInput -State $State -Title 'Migrate assignment records' -Prompt 'Version 1 JSON file' -Required + if ($null -eq $source) { return } + $destination = Read-IACTuiTextInput -State $State -Title 'Migrate assignment records' -Prompt 'Version 2 JSON destination' -Required + if ($null -eq $destination) { return } + Invoke-IACTuiCapturedOperation -State $State -ViewId Reports -SuccessMessage "Migrated records saved to $destination." -Operation { + $records = Get-Content -LiteralPath $source -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 100 + $converted = @($records | ConvertTo-IntuneAssignmentRecord) + $converted | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $destination -Encoding utf8NoBOM + $converted + } | Out-Null + } + 'ExportWorkspaceData' { + $availableViews = @($State.Registry | Where-Object { @($State.RawResults[$_.Id]).Count -gt 0 }) + if ($availableViews.Count -eq 0) { Set-IACTuiStatus -State $State -Message 'Load workspace results before exporting them.' -Style Warning; return } + $viewTitle = Read-IACTuiChoice -State $State -Title 'Save current results' -Prompt 'Choose the loaded workspace to export.' -Choice @($availableViews.Title) + if (-not $viewTitle) { return } + $selectedView = $availableViews | Where-Object Title -EQ $viewTitle | Select-Object -First 1 + $format = Read-IACTuiChoice -State $State -Title 'Save current results' -Prompt 'Choose a portable output format.' -Choice @('JSON', 'JSON Lines', 'CSV') + if (-not $format) { return } + $path = Read-IACTuiTextInput -State $State -Title 'Save current results' -Prompt 'Destination file path' -Required + if ($null -eq $path) { return } + $structuredFormat = @{ 'JSON' = 'Json'; 'JSON Lines' = 'JsonLines'; 'CSV' = 'Csv' }[$format] + if ((Test-Path -LiteralPath $path -PathType Leaf) -and + -not (Read-IACTuiConfirmation -State $State -Title 'Save current results' -Prompt 'A file already exists at this path. Replace it?')) { return } + try { + Export-IACStructuredOutput -InputObject @($State.RawResults[$selectedView.Id]) -Path $path -Format $structuredFormat + Set-IACTuiStatus -State $State -Message "$viewTitle results saved to $path." -Style Success + } + catch { Set-IACTuiStatus -State $State -Message $_.Exception.Message -Style Error } + } + 'ConnectTenant' { + $environment = Read-IACTuiChoice -State $State -Title 'Connect tenant' -Prompt 'Choose the Microsoft cloud.' -Choice @('Global', 'USGov', 'USGovDoD') + if (-not $environment) { return } + $capabilityProfile = @(Read-IACTuiMultiChoice -State $State -Title 'Connect tenant' -Prompt 'Choose the least-privilege capabilities needed for this session.' -Choice @('Core', 'Applications', 'Devices', 'Scripts', 'CloudPC', 'ScopeTags', 'Audit', 'Full') -DefaultChoice @('Full') | Where-Object { $null -ne $_ }) + if ($capabilityProfile.Count -eq 0) { return } + $authentication = Read-IACTuiChoice -State $State -Title 'Connect tenant' -Prompt 'Choose the authentication method.' -Choice @('Interactive sign-in', 'Certificate', 'Client secret', 'Access token') + if (-not $authentication) { return } + $tenant = Read-IACTuiTextInput -State $State -Title 'Connect tenant' -Prompt 'Tenant ID (blank lets sign-in choose)' + if ($null -eq $tenant) { return } + $parameters = @{ Environment = $environment; Capability = $capabilityProfile; PassThru = $true } + if ($tenant) { $parameters.TenantId = $tenant } + if ($authentication -in @('Interactive sign-in', 'Certificate', 'Client secret')) { + $appId = Read-IACTuiTextInput -State $State -Title 'Connect tenant' -Prompt $(if ($authentication -eq 'Interactive sign-in') { 'Application ID (optional)' } else { 'Application ID' }) -Required:($authentication -ne 'Interactive sign-in') + if ($null -eq $appId) { return } + if ($appId) { $parameters.AppId = $appId } + } + switch ($authentication) { + 'Certificate' { + if (-not $tenant) { Set-IACTuiStatus -State $State -Message 'Certificate sign-in requires a tenant ID.' -Style Warning; return } + $thumbprint = Read-IACTuiTextInput -State $State -Title 'Connect tenant' -Prompt 'Certificate thumbprint' -Required + if ($null -eq $thumbprint) { return }; $parameters.CertificateThumbprint = $thumbprint + } + 'Client secret' { + if (-not $tenant) { Set-IACTuiStatus -State $State -Message 'Client-secret sign-in requires a tenant ID.' -Style Warning; return } + $secret = Read-IACTuiSecretInput -State $State -Title 'Connect tenant' -Prompt 'Client secret' + if ($null -eq $secret) { return } + $parameters.ClientSecretCredential = [PSCredential]::new($parameters.AppId, $secret) + } + 'Access token' { + $token = Read-IACTuiSecretInput -State $State -Title 'Connect tenant' -Prompt 'Pre-fetched Microsoft Graph access token' + if ($null -eq $token) { return }; $parameters.AccessToken = $token + } + } + Invoke-IACTuiCapturedOperation -State $State -ViewId Settings -SuccessMessage 'Tenant connected.' -Operation { Connect-IntuneAssignmentChecker @parameters } | Out-Null + } + 'SwitchTenant' { + if (-not (Read-IACTuiConfirmation -State $State -Title 'Switch tenant' -Prompt 'Clear tenant-scoped caches and start a new sign-in?')) { return } + $environment = Read-IACTuiChoice -State $State -Title 'Switch tenant' -Prompt 'Choose the Microsoft cloud.' -Choice @('Global', 'USGov', 'USGovDoD') + if (-not $environment) { return } + $capabilityProfile = @(Read-IACTuiMultiChoice -State $State -Title 'Switch tenant' -Prompt 'Choose the capabilities needed in the next tenant.' -Choice @('Core', 'Applications', 'Devices', 'Scripts', 'CloudPC', 'ScopeTags', 'Audit', 'Full') -DefaultChoice @('Full') | Where-Object { $null -ne $_ }) + if ($capabilityProfile.Count -eq 0) { return } + $authentication = Read-IACTuiChoice -State $State -Title 'Switch tenant' -Prompt 'Choose the authentication method.' -Choice @('Interactive sign-in', 'Certificate', 'Client secret', 'Access token') + if (-not $authentication) { return } + $tenant = Read-IACTuiTextInput -State $State -Title 'Switch tenant' -Prompt 'Tenant ID (blank lets sign-in choose)' + if ($null -eq $tenant) { return } + $parameters = @{ Environment = $environment; Capability = $capabilityProfile; PassThru = $true } + if ($tenant) { $parameters.TenantId = $tenant } + if ($authentication -in @('Interactive sign-in', 'Certificate', 'Client secret')) { + $appId = Read-IACTuiTextInput -State $State -Title 'Switch tenant' -Prompt $(if ($authentication -eq 'Interactive sign-in') { 'Application ID (optional)' } else { 'Application ID' }) -Required:($authentication -ne 'Interactive sign-in') + if ($null -eq $appId) { return } + if ($appId) { $parameters.AppId = $appId } + } + switch ($authentication) { + 'Certificate' { + if (-not $tenant) { Set-IACTuiStatus -State $State -Message 'Certificate sign-in requires a tenant ID.' -Style Warning; return } + $thumbprint = Read-IACTuiTextInput -State $State -Title 'Switch tenant' -Prompt 'Certificate thumbprint' -Required + if ($null -eq $thumbprint) { return }; $parameters.CertificateThumbprint = $thumbprint + } + 'Client secret' { + if (-not $tenant) { Set-IACTuiStatus -State $State -Message 'Client-secret sign-in requires a tenant ID.' -Style Warning; return } + $secret = Read-IACTuiSecretInput -State $State -Title 'Switch tenant' -Prompt 'Client secret' + if ($null -eq $secret) { return } + $parameters.ClientSecretCredential = [PSCredential]::new($parameters.AppId, $secret) + } + 'Access token' { + $token = Read-IACTuiSecretInput -State $State -Title 'Switch tenant' -Prompt 'Pre-fetched Microsoft Graph access token' + if ($null -eq $token) { return }; $parameters.AccessToken = $token + } + } + Invoke-IACTuiCapturedOperation -State $State -ViewId Settings -SuccessMessage 'Tenant switched.' -Operation { Switch-IntuneAssignmentCheckerTenant @parameters } | Out-Null + } + 'RunDiagnostics' { + $parameters = if ($script:GraphEndpoint) { @{} } else { @{ SkipGraphProbe = $true } } + Invoke-IACTuiCapturedOperation -State $State -ViewId Settings -SuccessMessage 'Environment diagnostics complete.' -Operation { Test-IntuneAssignmentCheckerEnvironment @parameters } | Out-Null + } + 'RefreshSettingDefinitions' { + if (-not (Test-IACTuiConnected -State $State)) { return } + if (-not (Read-IACTuiConfirmation -State $State -Title 'Refresh setting catalog' -Prompt 'Download the latest setting definitions to the local cache?')) { return } + Invoke-IACTuiCapturedOperation -State $State -ViewId Settings -SuccessMessage 'Setting definitions refreshed.' -Operation { Update-IntuneSettingDefinition } | Out-Null + } + 'ConfigureScopeFilter' { + $scopeTag = Read-IACTuiTextInput -State $State -Title 'Assignment scope filter' -Prompt 'Scope-tag name (blank clears the filter)' -DefaultValue $State.Settings.ScopeTagFilter + if ($null -eq $scopeTag) { return } + $State.Settings.ScopeTagFilter = $scopeTag + Set-IACTuiStatus -State $State -Message $(if ($scopeTag) { "Assignment searches will use scope tag '$scopeTag'." } else { 'Assignment scope filter cleared.' }) -Style Success + } + default { Set-IACTuiStatus -State $State -Message "This action is not available: $ActionId" -Style Warning } + } +} + +function Show-IACTuiHelp { + [CmdletBinding()] + param([Parameter(Mandatory)]$State) + + $choice = Read-IACTuiChoice -State $State -Title 'Command center help' -Prompt 'Mouse: click workspaces, actions, and rows; use the wheel to scroll. Keyboard: Tab changes focus, arrows or J/K move, Enter opens a workspace, R runs its primary action, / filters, T opens Settings, Escape goes back, and Q or Ctrl+C exits.' -Choice @('Return') + $null = $choice +} + +function Invoke-IACTuiInputEvent { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$State, + [Parameter(Mandatory)]$InputEvent, + [switch]$SkipActionInvoke + ) + + if ($InputEvent.Kind -eq 'Mouse') { + if ($InputEvent.Action -eq 'Wheel') { + if ($InputEvent.X -lt $State.LastNavigationWidth) { + $State.NavigationIndex = [math]::Max(0, [math]::Min($State.Registry.Count - 1, $State.NavigationIndex - $InputEvent.WheelDelta)) + $State.Focus = 'Navigation' + } + else { + $rows = @(Get-IACTuiViewRows -State $State) + $State.SelectedIndex = [math]::Max(0, [math]::Min([math]::Max(0, $rows.Count - 1), $State.SelectedIndex - $InputEvent.WheelDelta)) + $State.Focus = 'Content' + } + return + } + if ($InputEvent.Button -ne 'Left' -or $InputEvent.Action -ne 'Down') { return } + $target = Get-IACTuiHitTarget -State $State -X $InputEvent.X -Y $InputEvent.Y + if (-not $target) { return } + switch ($target.Action) { + 'Navigate' { Set-IACTuiActiveView -State $State -ViewId "$($target.Value)"; $State.Focus = 'Navigation' } + 'SelectRow' { $State.SelectedIndex = [int]$target.Value; $State.Focus = 'Content' } + 'InvokeAction' { if (-not $SkipActionInvoke) { Invoke-IACTuiWorkflowAction -State $State -ActionId "$($target.Value)" } } + } + return + } + + if ($InputEvent.Kind -ne 'Key') { return } + $rows = @(Get-IACTuiViewRows -State $State) + switch ($InputEvent.Key) { + 'Tab' { $State.Focus = if ($State.Focus -eq 'Navigation') { 'Content' } else { 'Navigation' } } + 'UpArrow' { + if ($State.Focus -eq 'Navigation') { $State.NavigationIndex = [math]::Max(0, $State.NavigationIndex - 1) } + else { $State.SelectedIndex = [math]::Max(0, $State.SelectedIndex - 1) } + } + 'DownArrow' { + if ($State.Focus -eq 'Navigation') { $State.NavigationIndex = [math]::Min($State.Registry.Count - 1, $State.NavigationIndex + 1) } + else { $State.SelectedIndex = [math]::Min([math]::Max(0, $rows.Count - 1), $State.SelectedIndex + 1) } + } + 'PageUp' { + if ($State.Focus -eq 'Navigation') { $State.NavigationIndex = [math]::Max(0, $State.NavigationIndex - 10) } + else { $State.SelectedIndex = [math]::Max(0, $State.SelectedIndex - 10) } + } + 'PageDown' { + if ($State.Focus -eq 'Navigation') { $State.NavigationIndex = [math]::Min($State.Registry.Count - 1, $State.NavigationIndex + 10) } + else { $State.SelectedIndex = [math]::Min([math]::Max(0, $rows.Count - 1), $State.SelectedIndex + 10) } + } + 'Home' { if ($State.Focus -eq 'Navigation') { $State.NavigationIndex = 0 } else { $State.SelectedIndex = 0 } } + 'End' { if ($State.Focus -eq 'Navigation') { $State.NavigationIndex = $State.Registry.Count - 1 } else { $State.SelectedIndex = [math]::Max(0, $rows.Count - 1) } } + 'Enter' { + if ($State.Focus -eq 'Navigation') { Set-IACTuiActiveView -State $State -ViewId $State.Registry[$State.NavigationIndex].Id; $State.Focus = 'Content' } + } + 'Escape' { + if ($State.Filter) { $State.Filter = ''; $State.SelectedIndex = 0; $State.RowOffset = 0 } + elseif ($State.Focus -eq 'Content') { $State.Focus = 'Navigation' } + else { Set-IACTuiStatus -State $State -Message 'Press Q to leave the command center.' -Style Muted } + } + default { + $character = $InputEvent.Character + if (Test-IACTuiControlCEvent -InputEvent $InputEvent) { $State.ExitRequested = $true; return } + if ($character -in @('q', 'Q')) { $State.ExitRequested = $true; return } + if ($character -in @('j', 'J')) { + if ($State.Focus -eq 'Navigation') { $State.NavigationIndex = [math]::Min($State.Registry.Count - 1, $State.NavigationIndex + 1) } + else { $State.SelectedIndex = [math]::Min([math]::Max(0, $rows.Count - 1), $State.SelectedIndex + 1) } + return + } + if ($character -in @('k', 'K')) { + if ($State.Focus -eq 'Navigation') { $State.NavigationIndex = [math]::Max(0, $State.NavigationIndex - 1) } + else { $State.SelectedIndex = [math]::Max(0, $State.SelectedIndex - 1) } + return + } + if ($character -in @('t', 'T') -and $State.ActiveViewId -ne 'Settings') { Set-IACTuiActiveView -State $State -ViewId Settings; return } + if ($character -eq '?') { if (-not $SkipActionInvoke) { Show-IACTuiHelp -State $State }; return } + if ($character -eq '/') { + if (-not $SkipActionInvoke) { + $filter = Read-IACTuiTextInput -State $State -Title 'Filter results' -Prompt 'Match a title, status, or detail' -DefaultValue $State.Filter + if ($null -ne $filter) { $State.Filter = $filter; $State.SelectedIndex = 0; $State.RowOffset = 0 } + } + return + } + if ([string]::IsNullOrEmpty($character) -or [char]::IsControl($character[0])) { return } + $feature = $State.Registry | Where-Object Id -EQ $State.ActiveViewId | Select-Object -First 1 + $action = if ($character -in @('r', 'R')) { @($feature.Actions | Where-Object Primary | Select-Object -First 1) } + else { @($feature.Actions | Where-Object { $_.Key -ceq [char]::ToUpperInvariant([char]$character) } | Select-Object -First 1) } + if ($action.Count -gt 0) { + if (-not $SkipActionInvoke) { Invoke-IACTuiWorkflowAction -State $State -ActionId $action[0].Id } + return + } + } + } +} diff --git a/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 index d79c2cc..d8f6adb 100644 --- a/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Compare-IntuneGroupAssignment.ps1 @@ -11,7 +11,10 @@ function Compare-IntuneGroupAssignment { [switch]$ExportToCSV, [Parameter()] - [string]$ExportPath + [string]$ExportPath, + + [Parameter()] + [switch]$PassThru ) Write-Host "Compare Group Assignments chosen" -ForegroundColor Green @@ -447,4 +450,5 @@ function Compare-IntuneGroupAssignment { } } } + if ($PassThru) { $comparisonResults } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentOperation.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentOperation.ps1 index 8bbb518..2b91bb4 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentOperation.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneAssignmentOperation.ps1 @@ -1,13 +1,13 @@ function Get-IntuneAssignmentOperation { <# .SYNOPSIS - Returns the operation catalog used by the terminal UI. + Returns structured metadata for exported module operations. .DESCRIPTION Discovers every exported operational command and returns structured metadata for its help, capabilities, parameter sets, parameters, and validation choices. - The terminal UI consumes this catalog directly, which keeps it in parity with - the PowerShell module without a second command implementation. + This is an automation and documentation surface. The task-oriented terminal + interface uses its own workflow registry backed by the same module functions. .PARAMETER Name Optional wildcard pattern used to filter command names. diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneEmptyGroup.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneEmptyGroup.ps1 index 56d9716..28444ba 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneEmptyGroup.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneEmptyGroup.ps1 @@ -5,7 +5,10 @@ function Get-IntuneEmptyGroup { [switch]$ExportToCSV, [Parameter()] - [string]$ExportPath + [string]$ExportPath, + + [Parameter()] + [switch]$PassThru ) Write-Host "Checking for policies assigned to empty groups..." -ForegroundColor Green @@ -446,5 +449,6 @@ function Get-IntuneEmptyGroup { } # Export results if requested - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneEmptyGroupAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneEmptyGroupAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$PassThru + if ($PassThru) { $exportData } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneFailedAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneFailedAssignment.ps1 index 2734cc3..0e0c96e 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneFailedAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneFailedAssignment.ps1 @@ -5,7 +5,10 @@ function Get-IntuneFailedAssignment { [switch]$ExportToCSV, [Parameter()] - [string]$ExportPath + [string]$ExportPath, + + [Parameter()] + [switch]$PassThru ) Write-Host "Fetching all failed assignments..." -ForegroundColor Green @@ -47,6 +50,7 @@ function Get-IntuneFailedAssignment { } # Export if requested - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneFailedAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneFailedAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$PassThru } + if ($PassThru) { $exportData } } diff --git a/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 b/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 index a71b015..f2068bb 100644 --- a/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Get-IntuneUserDeviceAssignment.ps1 @@ -26,7 +26,10 @@ function Get-IntuneUserDeviceAssignment { [string]$ExportPath, [Parameter(Mandatory = $false)] - [string]$ScopeTagFilter + [string]$ScopeTagFilter, + + [Parameter(Mandatory = $false)] + [switch]$PassThru ) Write-Host "What-If: User on Device - effective policy preview" -ForegroundColor Green @@ -602,5 +605,6 @@ function Get-IntuneUserDeviceAssignment { Add-ExportData -ExportData $exportData -Category "Windows Driver Update Profile" -Items $relevantPolicies.WindowsDriverUpdates -AssignmentReason { param($i) "$($i.Source) | $($i.AssignmentReason)" } Add-ExportData -ExportData $exportData -Category "Windows Quality Update Policy" -Items $relevantPolicies.WindowsQualityUpdatePolicies -AssignmentReason { param($i) "$($i.Source) | $($i.AssignmentReason)" } - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneUserDeviceAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneUserDeviceAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$PassThru + if ($PassThru) { $exportData } } diff --git a/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentChecker.ps1 b/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentChecker.ps1 index 48b9a09..e52e202 100644 --- a/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentChecker.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Invoke-IntuneAssignmentChecker.ps1 @@ -163,6 +163,27 @@ function Invoke-IntuneAssignmentChecker { if ($AccessToken -and $AccessToken.Length -gt 0) { $connectParams['AccessToken'] = $AccessToken } if ($Environment) { $connectParams['Environment'] = $Environment } + # A plain invocation opens the command center immediately. Connection is a + # first-class Settings workflow, so users can inspect offline snapshots and + # diagnostics without signing in first. Explicit authentication arguments + # retain the convenient connect-then-open behavior. + if (-not $parameterMode) { + $connectionParameterNames = @( + 'AppId', 'TenantId', 'CertificateThumbprint', 'ClientSecret', + 'ClientSecretCredential', 'AccessToken', 'Environment' + ) + $explicitConnectionRequest = @($connectionParameterNames | Where-Object { $PSBoundParameters.ContainsKey($_) }).Count -gt 0 + if ($explicitConnectionRequest) { + Connect-IntuneAssignmentChecker @connectParams + if (-not (Get-MgContext -ErrorAction SilentlyContinue)) { + Write-Host "Not connected to Microsoft Graph. Exiting." -ForegroundColor Red + return + } + } + Start-IntuneAssignmentCheckerTui + return + } + Connect-IntuneAssignmentChecker @connectParams # Abort if connection failed (no Graph context) @@ -171,14 +192,6 @@ function Invoke-IntuneAssignmentChecker { return } - # The v5 interactive surface is generated from the exported command catalog. - # Keep the legacy feature switches below for non-interactive compatibility, - # but route the alias/default invocation to the full-parity terminal UI. - if (-not $parameterMode) { - Start-IntuneAssignmentCheckerTui - return - } - # The legacy feature switches remain one-shot compatibility entry points. # Interactive navigation and tenant switching now live in the exported TUI. $selection = $selectedOption @@ -240,19 +253,22 @@ function Invoke-IntuneAssignmentChecker { '9' { Get-IntuneEmptyGroup ` -ExportToCSV:$ExportToCSV ` - -ExportPath $ExportPath + -ExportPath $ExportPath ` + -PassThru | Out-Null } '10' { Compare-IntuneGroupAssignment ` -CompareGroupNames $CompareGroupNames ` -IncludeNestedGroups:$IncludeNestedGroups ` -ExportToCSV:$ExportToCSV ` - -ExportPath $ExportPath + -ExportPath $ExportPath ` + -PassThru | Out-Null } '11' { Get-IntuneFailedAssignment ` -ExportToCSV:$ExportToCSV ` - -ExportPath $ExportPath + -ExportPath $ExportPath ` + -PassThru | Out-Null } '12' { Test-IntuneGroupMembership ` @@ -262,7 +278,8 @@ function Invoke-IntuneAssignmentChecker { -GroupNames $GroupNames ` -ExportToCSV:$ExportToCSV ` -ExportPath $ExportPath ` - -ScopeTagFilter $ScopeTagFilter + -ScopeTagFilter $ScopeTagFilter ` + -PassThru | Out-Null } '13' { Test-IntuneGroupRemoval ` @@ -272,7 +289,8 @@ function Invoke-IntuneAssignmentChecker { -GroupNames $GroupNames ` -ExportToCSV:$ExportToCSV ` -ExportPath $ExportPath ` - -ScopeTagFilter $ScopeTagFilter + -ScopeTagFilter $ScopeTagFilter ` + -PassThru | Out-Null } '14' { Search-IntunePolicy ` @@ -284,7 +302,8 @@ function Invoke-IntuneAssignmentChecker { Search-IntuneSetting ` -Keyword $SettingKeyword ` -ExportToCSV:$ExportToCSV ` - -ExportPath $ExportPath + -ExportPath $ExportPath ` + -PassThru | Out-Null } '16' { Get-IntuneUserDeviceAssignment ` @@ -292,7 +311,8 @@ function Invoke-IntuneAssignmentChecker { -DeviceName $DeviceNames ` -ExportToCSV:$ExportToCSV ` -ExportPath $ExportPath ` - -ScopeTagFilter $ScopeTagFilter + -ScopeTagFilter $ScopeTagFilter ` + -PassThru | Out-Null } } } diff --git a/Module/IntuneAssignmentChecker/Public/Search-IntuneSetting.ps1 b/Module/IntuneAssignmentChecker/Public/Search-IntuneSetting.ps1 index b653b1a..d1d6204 100644 --- a/Module/IntuneAssignmentChecker/Public/Search-IntuneSetting.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Search-IntuneSetting.ps1 @@ -11,7 +11,10 @@ function Search-IntuneSetting { [switch]$ExportToCSV, [Parameter()] - [string]$ExportPath + [string]$ExportPath, + + [Parameter()] + [switch]$PassThru ) # Requires active Graph connection @@ -290,7 +293,8 @@ function Search-IntuneSetting { Write-Host (Get-Separator -Character "=") -ForegroundColor Cyan # ── Export ─────────────────────────────────────────────────────────── - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneSettingSearch.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneSettingSearch.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$PassThru + if ($PassThru) { $exportData } } # ── Helper: extract configured value from a setting instance ───────── diff --git a/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 b/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 index 35d9f59..cf80f5d 100644 --- a/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 @@ -1,104 +1,65 @@ function Start-IntuneAssignmentCheckerTui { <# .SYNOPSIS - Starts the keyboard-driven IntuneAssignmentChecker terminal interface. + Opens the IntuneAssignmentChecker assignment-governance command center. .DESCRIPTION - Presents the same operational surface as the PowerShell module. Commands, - parameter sets, mandatory inputs, switches, credentials, secure strings, and - ValidateSet choices are discovered dynamically from the exported cmdlets. - No separate application logic or converted executable is used. + Starts a task-oriented terminal interface for assignment discovery, + governance, change simulation, drift, delivery health, RBAC, filters, + fleet scans, reports, and connection settings. The interface uses the same + PowerShell implementation as direct module commands and does not require a + converted executable. - .PARAMETER InitialFilter - Filters the initial operation list by name, category, synopsis, or capability. + Mouse input is enabled in terminals that support SGR mouse reporting. + Every mouse action has a keyboard equivalent. Use -DisableMouse when a + terminal multiplexer or accessibility tool needs to retain mouse events. - .PARAMETER Command - Opens the parameter editor for one command directly. + .PARAMETER InitialView + Workspace shown when the command center opens. + + .PARAMETER DisableMouse + Keeps terminal mouse reporting disabled while preserving keyboard control. #> [CmdletBinding()] param( [Parameter()] - [string]$InitialFilter, + [ValidateSet('Overview', 'Assignments', 'Governance', 'Simulator', 'Drift', 'Health', 'Access', 'Filters', 'Fleet', 'Reports', 'Settings')] + [string]$InitialView = 'Overview', [Parameter()] - [string]$Command + [switch]$DisableMouse ) if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { - throw 'The terminal UI requires an interactive terminal. Use the individual module cmdlets for automation.' + throw 'The terminal UI requires an interactive terminal. Use the individual module commands for automation.' } - $catalog = @(Get-IACOperationCatalog) - if ($Command) { - $operation = $catalog | Where-Object Name -EQ $Command | Select-Object -First 1 - if (-not $operation) { throw "Unknown IntuneAssignmentChecker operation '$Command'." } - Show-IACTuiOperation -Operation $operation - return + $module = Get-Module IntuneAssignmentChecker | Select-Object -First 1 + $operationalExports = @($module.ExportedFunctions.Keys | Where-Object { + $_ -notin @('Get-IntuneAssignmentOperation', 'Invoke-IntuneAssignmentChecker', 'Start-IntuneAssignmentCheckerTui') + }) + $parity = Test-IACTuiFeatureParity -CommandName $operationalExports + if (-not $parity.Complete) { + throw "The terminal workflow registry is incomplete. Missing: $($parity.Missing -join ', '); unknown: $($parity.Unknown -join ', ')." } - $filter = "$InitialFilter" - $selectedIndex = 0 - while ($true) { - $operations = if ([string]::IsNullOrWhiteSpace($filter)) { @($catalog) } - else { - @($catalog | Where-Object { - $_.Name -like "*$filter*" -or $_.Category -like "*$filter*" -or - $_.Synopsis -like "*$filter*" -or (@($_.Capabilities) -join ' ') -like "*$filter*" - }) + $state = New-IACTuiState -InitialView $InitialView + try { + $terminal = Enable-IACTuiTerminal -State $state -DisableMouse:$DisableMouse + if ($DisableMouse) { + Set-IACTuiStatus -State $state -Message 'Mouse input disabled; all features remain available from the keyboard.' -Style Muted } - if ($operations.Count -eq 0) { - $filter = '' - $selectedIndex = 0 - continue + elseif (-not $terminal.MouseEnabled) { + Set-IACTuiStatus -State $state -Message 'Mouse reporting is unavailable in this terminal; keyboard navigation is fully supported.' -Style Warning } - if ($selectedIndex -ge $operations.Count) { $selectedIndex = $operations.Count - 1 } - - Show-IACTuiScreen -Operations $operations -SelectedIndex $selectedIndex -Filter $filter - $key = [Console]::ReadKey($true) - switch ($key.Key) { - 'UpArrow' { if ($selectedIndex -gt 0) { $selectedIndex-- } } - 'DownArrow' { if ($selectedIndex -lt $operations.Count - 1) { $selectedIndex++ } } - 'PageUp' { $selectedIndex = [math]::Max(0, $selectedIndex - 10) } - 'PageDown' { $selectedIndex = [math]::Min($operations.Count - 1, $selectedIndex + 10) } - 'Home' { $selectedIndex = 0 } - 'End' { $selectedIndex = $operations.Count - 1 } - 'Enter' { Show-IACTuiOperation -Operation $operations[$selectedIndex] } - 'C' { - Show-IACTuiOperation -Operation ($catalog | Where-Object Name -EQ 'Switch-IntuneAssignmentCheckerTenant' | Select-Object -First 1) - } - 'T' { - Show-IACTuiOperation -Operation ($catalog | Where-Object Name -EQ 'Switch-IntuneAssignmentCheckerTenant' | Select-Object -First 1) - } - 'Q' { Clear-Host; return } - 'Escape' { Clear-Host; return } - 'Oem2' { - Write-Host '' - $filter = Read-Host 'Filter operations (blank clears)' - $selectedIndex = 0 - } - default { - if ($key.KeyChar -eq '/') { - Write-Host '' - $filter = Read-Host 'Filter operations (blank clears)' - $selectedIndex = 0 - } - elseif ($key.KeyChar -in @('j', 'J') -and $selectedIndex -lt $operations.Count - 1) { $selectedIndex++ } - elseif ($key.KeyChar -in @('k', 'K') -and $selectedIndex -gt 0) { $selectedIndex-- } - elseif ($key.KeyChar -eq '?') { - Clear-Host - Write-IACTuiText -Text 'Terminal UI help' -Style Accent - Write-Host @' -The TUI discovers its commands from the imported module. Select an operation, -choose a parameter set, and enter values. Optional values can be skipped with -Enter. Arrays accept comma-separated values or @path-to-json. Secure inputs are -never echoed. Press C to disconnect and connect to another tenant. Commands still return the same structured PowerShell objects and -use the same Microsoft Graph beta transport as direct cmdlet invocation. -'@ - Write-IACTuiText -Text 'Press any key to return.' -Style Muted -NoNewline - $null = [Console]::ReadKey($true) - } - } + while (-not $state.ExitRequested) { + Show-IACTuiFrame -State $state + $inputEvent = Read-IACTuiInput + Invoke-IACTuiInputEvent -State $state -InputEvent $inputEvent } } + finally { + Disable-IACTuiTerminal -State $state + } } diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 index 574f776..db67509 100644 --- a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupMembership.ps1 @@ -7,7 +7,8 @@ function Test-IntuneGroupMembership { [Parameter()][string]$GroupNames, [Parameter()][switch]$ExportToCSV, [Parameter()][string]$ExportPath, - [Parameter()][string]$ScopeTagFilter + [Parameter()][string]$ScopeTagFilter, + [Parameter()][switch]$PassThru ) Write-Host "Group Membership Impact Analysis selected" -ForegroundColor Green @@ -458,5 +459,6 @@ function Test-IntuneGroupMembership { }) } - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneGroupMembershipImpact.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneGroupMembershipImpact.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$PassThru + if ($PassThru) { $exportData } } diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 index 20115cb..e92ac1d 100644 --- a/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneGroupRemoval.ps1 @@ -20,7 +20,10 @@ function Test-IntuneGroupRemoval { [string]$ExportPath, [Parameter()] - [string]$ScopeTagFilter + [string]$ScopeTagFilter, + + [Parameter()] + [switch]$PassThru ) Write-Host "Group Membership Removal Impact Analysis selected" -ForegroundColor Green @@ -488,5 +491,6 @@ function Test-IntuneGroupRemoval { }) } - Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneGroupRemovalImpact.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode + Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneGroupRemovalImpact.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$PassThru + if ($PassThru) { $exportData } } diff --git a/README.md b/README.md index 4731976..88b3c1d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ ```powershell winget install --id UgurKoc.IntuneAssignmentChecker --exact -# Open PowerShell 7, then launch the full terminal UI +# Open PowerShell 7, then launch the command center pwsh Start-IntuneAssignmentCheckerTui ``` @@ -55,13 +55,15 @@ of IntuneAssignmentChecker. # Install from PowerShell Gallery Install-Module IntuneAssignmentChecker -Scope CurrentUser -# Launch the full-parity terminal UI +# Launch the assignment-governance command center Start-IntuneAssignmentCheckerTui ``` The legacy `IntuneAssignmentChecker` alias remains available. The v5 terminal UI -discovers the module's exported commands dynamically, so every module operation is -also available through `Start-IntuneAssignmentCheckerTui` without a separate UI codebase. +organizes every module capability into native task workspaces: Overview, Assignments, +Governance, Change simulator, Drift, Delivery health, RBAC & scope, Filters, Fleet, +Reports & data, and Settings. It is implemented in the same PowerShell source as the +cmdlets, so there is no second application codebase or converted executable. If you encounter any issues during installation, try reinstalling: @@ -92,7 +94,7 @@ Start-IntuneAssignmentCheckerTui ## ✨ Features -- 🖥️ Full-parity, dependency-free terminal UI generated from the module's real command metadata +- 🖥️ Full-parity PowerShell command center with purpose-built workflows, mouse support, and complete keyboard navigation - 🛡️ Policy-as-code assignment governance with severity, evidence, remediation, waivers, and automation exit behavior - 💥 Read-only pre-change simulation for target, filter, mode, and app-intent changes - 🔭 Capture-and-compare drift monitoring with approved baselines, risk classification, audit attribution, JSON Lines, and webhooks @@ -362,12 +364,15 @@ Entra ID → App registrations → Your App → API permissions → "Grant admin The module can be used in two ways: -1. **Terminal UI**: Full exported-command parity (`Start-IntuneAssignmentCheckerTui`) +1. **Terminal UI**: Task-oriented command center with full feature parity (`Start-IntuneAssignmentCheckerTui`) 2. **Cmdlet Mode**: Individual cmdlets for automation and scripting -The TUI is metadata-driven: it reads the same parameter sets, validation choices, -and help used by direct PowerShell calls. `Get-IntuneAssignmentOperation` exposes -that catalog for testing and integrations. +The TUI presents domain workflows instead of PowerShell syntax. Friendly dialogs +collect only the information each task needs, and results stay inside searchable +lists and detail panes. The workflows call the module's shared implementation, so +fixes apply to both the interactive and automation experiences. The separate +`Get-IntuneAssignmentOperation` command remains available as a metadata API for +documentation and integrations. ### 🖥️ Cmdlet Reference @@ -593,9 +598,9 @@ Available cmdlets: | `Search-IntunePolicy` | Reverse lookup: find all assignment targets for a policy name | | `Search-IntuneSetting` | Search configured settings across all policies | | `Update-IntuneSettingDefinition` | Refresh the local Settings Catalog definition cache | -| `Start-IntuneAssignmentCheckerTui` | Launch the metadata-driven terminal UI with parity across module commands | +| `Start-IntuneAssignmentCheckerTui` | Launch the task-oriented terminal command center with mouse and keyboard support | | `Switch-IntuneAssignmentCheckerTenant` | Clear tenant-scoped state and connect the TUI or shell to another tenant | -| `Get-IntuneAssignmentOperation` | Return the operation and parameter catalog used by the TUI | +| `Get-IntuneAssignmentOperation` | Return operation and parameter metadata for automation integrations | | `Invoke-IntuneAssignmentScan` | Run a budgeted, checkpointed assignment scan with coverage diagnostics | | `Test-IntuneAssignmentGovernance` | Evaluate assignment policy-as-code rules and waivers | | `Test-IntuneAssignmentChange` | Simulate a proposed assignment change without Graph writes | @@ -634,20 +639,21 @@ Common parameters on `Connect-IntuneAssignmentChecker`: ### 📋 Terminal UI controls -Run `Start-IntuneAssignmentCheckerTui` (or the `IntuneAssignmentChecker` alias -after connecting). The UI groups every exported operation by purpose and shows its -actual PowerShell help, capability profile, parameter sets, mandatory parameters, -switches, and validation choices. - -- Use Up/Down or J/K to navigate, Page Up/Page Down to jump, and Enter to run an operation. -- Press `/` to filter by command, category, synopsis, or capability. -- Press `C` to disconnect and open the tenant-switch connection command, `?` for help, or `Q` to quit. -- Enter comma-separated array values or `@path-to-json` for structured arrays. -- Credentials and secure strings use PowerShell's protected input prompts. - -Because this list is generated from exported command metadata, adding a public -module command automatically adds it to the TUI and to the parity test. There is -no second feature implementation to maintain. +Run `Start-IntuneAssignmentCheckerTui` or the `IntuneAssignmentChecker` alias. The +command center can open before authentication; connect or switch tenants from +Settings, or use offline snapshot workflows without signing in. + +- Click workspaces, action buttons, result rows, and dialog choices with the mouse; use the wheel to scroll. +- Use Tab to switch focus, Up/Down or J/K to navigate, Page Up/Page Down to jump, and Enter to open the highlighted workspace. A highlighted result updates the detail pane immediately. +- Press `R` for the workspace's primary action, `/` to filter loaded results, `T` for Settings, `?` for help, or `Q` to quit. +- Press Escape to clear a result filter or move focus back to the workspace list. Ctrl+C also exits cleanly. +- Pass `-DisableMouse` when a terminal multiplexer or accessibility tool should retain mouse events; every feature remains keyboard-accessible. +- Resize the terminal to at least 90 columns by 26 rows for the full two-pane layout. +- Set an optional scope-tag filter in Settings, and save any loaded workspace results from Reports & data as JSON, JSON Lines, or CSV. + +A central workflow registry maps every operational export to at least one task, +and release tests fail if that coverage is lost. Both interfaces remain one PowerShell +module codebase and share the same scanning, governance, simulation, and reporting logic. ## 🏃‍♂️ Example Runbook diff --git a/Tests/Unit/V5Platform.Tests.ps1 b/Tests/Unit/V5Platform.Tests.ps1 index 80f0f1d..084c513 100644 --- a/Tests/Unit/V5Platform.Tests.ps1 +++ b/Tests/Unit/V5Platform.Tests.ps1 @@ -10,41 +10,439 @@ AfterAll { Remove-Module IntuneAssignmentChecker -Force -ErrorAction SilentlyContinue } -Describe 'v5 terminal UI parity' { - It 'discovers every exported operation except its own catalog and UI infrastructure' { +Describe 'v5 task-oriented terminal UI' { + It 'maps every operational export to at least one friendly task' { $module = Get-Module IntuneAssignmentChecker $expected = @($module.ExportedFunctions.Keys | Where-Object { $_ -notin @('Get-IntuneAssignmentOperation', 'Invoke-IntuneAssignmentChecker', 'Start-IntuneAssignmentCheckerTui') } | Sort-Object) - $actual = @((Get-IntuneAssignmentOperation).Name | Sort-Object) + $parity = & $module { param($CommandName) Test-IACTuiFeatureParity -CommandName $CommandName } $expected - $actual | Should -Be $expected + $parity.Complete | Should -BeTrue + $parity.Mapped | Should -Be $expected + $parity.Missing.Count | Should -Be 0 + $parity.Unknown.Count | Should -Be 0 } - It 'exposes parameter sets and editable parameter metadata from the real command' { - $operation = Get-IntuneAssignmentOperation -Name Test-IntuneAssignmentGovernance + It 'provides the approved Command Center workspaces' { + $registry = & (Get-Module IntuneAssignmentChecker) { Get-IACTuiFeatureRegistry } - $operation.ParameterSets.Count | Should -BeGreaterThan 1 - @($operation.ParameterSets.Parameters.Name) | Should -Contain 'SnapshotPath' - @($operation.ParameterSets.Parameters.Name) | Should -Contain 'FailOnSeverity' + $registry.Id | Should -Be @('Overview', 'Assignments', 'Governance', 'Simulator', 'Drift', 'Health', 'Access', 'Filters', 'Fleet', 'Reports', 'Settings') + $registry.Title | Should -Contain 'Change simulator' + $registry.Title | Should -Contain 'RBAC & scope' + $registry.Title | Should -Contain 'Reports & data' + } + + It 'keeps command names and PowerShell parameter syntax out of visible workflow copy' { + $registry = @(& (Get-Module IntuneAssignmentChecker) { Get-IACTuiFeatureRegistry }) + $visibleCopy = @( + $registry.Title + $registry.Summary + $registry.Actions.Label + $registry.Actions.Description + ) -join "`n" + + foreach ($commandName in @($registry.Commands)) { + $visibleCopy | Should -Not -Match ([regex]::Escape($commandName)) + } + $visibleCopy | Should -Not -Match '(?m)^\s*-[A-Z][A-Za-z]+' + } + + It 'renders the Draft A layout with the Draft B palette and clickable targets' { + $result = & (Get-Module IntuneAssignmentChecker) { + $state = New-IACTuiState + $plain = Get-IACTuiFrame -State $state -Width 120 -Height 36 + $ansi = Get-IACTuiFrame -State $state -Width 120 -Height 36 -Ansi + [PSCustomObject]@{ Plain = $plain; Ansi = $ansi; HitTargets = @($state.HitTargets) } + } + + $result.Plain | Should -Match 'INTUNE ASSIGNMENT CHECKER' + $result.Plain | Should -Match 'WORKSPACES' + $result.Plain | Should -Match 'PRIORITY FINDINGS' + $result.Plain | Should -Not -Match 'Test-IntuneAssignmentGovernance' + @($result.Plain -split "`n").Count | Should -Be 36 + @($result.Plain -split "`n" | Where-Object Length -NE 120).Count | Should -Be 0 + $result.Ansi | Should -Match ([regex]::Escape("`e[38;2;244;184;96m")) + $result.Ansi | Should -Match ([regex]::Escape("`e[48;2;244;184;96m")) + # Eleven workspace rows plus the clickable tenant/profile indicator. + @($result.HitTargets | Where-Object Action -EQ Navigate).Count | Should -Be 12 + @($result.HitTargets | Where-Object Action -EQ InvokeAction).Count | Should -BeGreaterThan 0 + } + + It 'wraps every workspace action into a mouse-accessible button' { + $counts = & (Get-Module IntuneAssignmentChecker) { + $state = New-IACTuiState -InitialView Assignments + $null = Get-IACTuiFrame -State $state -Width 120 -Height 36 + [PSCustomObject]@{ + Expected = @(($state.Registry | Where-Object Id -EQ Assignments).Actions).Count + Actual = @($state.HitTargets | Where-Object Action -EQ InvokeAction).Count + } + } + + $counts.Actual | Should -Be $counts.Expected + $counts.Actual | Should -BeGreaterThan 1 + } + + It 'parses SGR clicks, releases, movement, modifiers, and wheel input' { + $events = & (Get-Module IntuneAssignmentChecker) { + @( + ConvertFrom-IACTuiInputSequence -Sequence "`e[<0;12;7M" + ConvertFrom-IACTuiInputSequence -Sequence "`e[<0;12;7m" + ConvertFrom-IACTuiInputSequence -Sequence "`e[<36;5;9M" + ConvertFrom-IACTuiInputSequence -Sequence "`e[<64;9;4M" + ConvertFrom-IACTuiInputSequence -Sequence "`e[<65;9;4M" + ) + } + + $events[0].Kind | Should -BeExactly Mouse + $events[0].Button | Should -BeExactly Left + $events[0].Action | Should -BeExactly Down + $events[0].X | Should -Be 11 + $events[0].Y | Should -Be 6 + $events[1].Action | Should -BeExactly Up + $events[2].Action | Should -BeExactly Move + $events[2].Shift | Should -BeTrue + $events[3].WheelDelta | Should -Be 1 + $events[4].WheelDelta | Should -Be -1 + } + + It 'resolves the topmost mouse target and routes clicks without executing workflows' { + $result = & (Get-Module IntuneAssignmentChecker) { + $state = New-IACTuiState + Add-IACTuiHitTarget -State $state -X 1 -Y 1 -Width 8 -Height 2 -Action Navigate -Value Overview + Add-IACTuiHitTarget -State $state -X 3 -Y 1 -Width 3 -Height 1 -Action Navigate -Value Governance + $resolved = Get-IACTuiHitTarget -State $state -X 4 -Y 1 + Invoke-IACTuiInputEvent -State $state -InputEvent ([PSCustomObject]@{ + Kind = 'Mouse'; X = 4; Y = 1; Button = 'Left'; Action = 'Down'; WheelDelta = 0 + }) -SkipActionInvoke + [PSCustomObject]@{ Resolved = $resolved; ActiveViewId = $state.ActiveViewId } + } + + $result.Resolved.Value | Should -BeExactly Governance + $result.ActiveViewId | Should -BeExactly Governance + } + + It 'provides full keyboard navigation when mouse reporting is unavailable' { + $state = & (Get-Module IntuneAssignmentChecker) { + $state = New-IACTuiState + Invoke-IACTuiInputEvent -State $state -InputEvent (New-IACTuiKeyEvent -Key DownArrow) -SkipActionInvoke + Invoke-IACTuiInputEvent -State $state -InputEvent (New-IACTuiKeyEvent -Key Enter) -SkipActionInvoke + Invoke-IACTuiInputEvent -State $state -InputEvent (New-IACTuiKeyEvent -Key Tab) -SkipActionInvoke + $state + } + + $state.ActiveViewId | Should -BeExactly Assignments + $state.Focus | Should -BeExactly Navigation + } + + It 'keeps tiny, minimum, and oversized terminal frames renderable' { + $frames = & (Get-Module IntuneAssignmentChecker) { + $state = New-IACTuiState -InitialView Assignments + [PSCustomObject]@{ + Tiny = Get-IACTuiFrame -State $state -Width 1 -Height 1 + Small = Get-IACTuiFrame -State $state -Width 10 -Height 5 + Minimum = Get-IACTuiFrame -State $state -Width 90 -Height 26 + Oversized = Get-IACTuiFrame -State $state -Width 1200 -Height 600 + } + } + + $frames.Tiny.Length | Should -Be 1 + @($frames.Small -split "`n").Count | Should -Be 5 + $frames.Minimum | Should -Match 'DETAIL' + @($frames.Minimum -split "`n").Count | Should -Be 26 + @($frames.Oversized -split "`n").Count | Should -Be 500 + } + + It 'combines Windows VT and mouse flags while disabling Quick Edit' { + $mode = & (Get-Module IntuneAssignmentChecker) { Get-IACWindowsTuiInputMode -Mode ([uint32]0x0041) } + + ($mode -band 0x0200) | Should -Be 0x0200 + ($mode -band 0x0010) | Should -Be 0x0010 + ($mode -band 0x0080) | Should -Be 0x0080 + ($mode -band 0x0040) | Should -Be 0 + ($mode -band 0x0001) | Should -Be 0x0001 + } + + It 'preserves keyboard-selected least-privilege capability combinations' { + $selection = @(& (Get-Module IntuneAssignmentChecker) { + $queue = [Collections.Generic.Queue[object]]::new() + foreach ($key in @('Spacebar', 'DownArrow', 'Spacebar', 'DownArrow', 'DownArrow', 'Enter')) { + $queue.Enqueue((New-IACTuiKeyEvent -Key $key)) + } + $reader = { $queue.Dequeue() }.GetNewClosure() + $state = New-IACTuiState + Read-IACTuiMultiChoice -State $state -Title 'Test' -Prompt 'Test' ` + -Choice @('Core', 'Audit', 'Full') -DefaultChoice @('Full') ` + -InputProvider $reader -SuppressRender + }) + + $selection | Should -Be @('Core', 'Audit') + $selection | Should -Not -Contain Full + } + + It 'stops connect and switch workflows when capability selection is cancelled' { + Mock Read-IACTuiChoice -ModuleName IntuneAssignmentChecker { 'Global' } + Mock Read-IACTuiMultiChoice -ModuleName IntuneAssignmentChecker { $null } + Mock Read-IACTuiConfirmation -ModuleName IntuneAssignmentChecker { $true } + Mock Read-IACTuiTextInput -ModuleName IntuneAssignmentChecker { throw 'The workflow continued after cancellation.' } + Mock Connect-IntuneAssignmentChecker -ModuleName IntuneAssignmentChecker {} + Mock Switch-IntuneAssignmentCheckerTenant -ModuleName IntuneAssignmentChecker {} + + & (Get-Module IntuneAssignmentChecker) { + $state = New-IACTuiState -InitialView Settings + Invoke-IACTuiWorkflowAction -State $state -ActionId ConnectTenant + Invoke-IACTuiWorkflowAction -State $state -ActionId SwitchTenant + } + + Should -Invoke Read-IACTuiMultiChoice -ModuleName IntuneAssignmentChecker -Times 2 + Should -Invoke Read-IACTuiTextInput -ModuleName IntuneAssignmentChecker -Times 0 + Should -Invoke Connect-IntuneAssignmentChecker -ModuleName IntuneAssignmentChecker -Times 0 + Should -Invoke Switch-IntuneAssignmentCheckerTenant -ModuleName IntuneAssignmentChecker -Times 0 + } + + It 'separates notices, preserves partial data, and fails only empty error results' { + $result = & (Get-Module IntuneAssignmentChecker) { + $warningState = New-IACTuiState -InitialView Settings + $null = Invoke-IACTuiCapturedOperation -State $warningState -ViewId Settings -SuccessMessage 'Completed.' -SuppressRender -Operation { + Write-Warning 'Partial workload coverage.' + [PSCustomObject]@{ Title = 'Result'; Status = 'Available' } + } + $partialState = New-IACTuiState -InitialView Assignments + $partialOutput = @(Invoke-IACTuiCapturedOperation -State $partialState -ViewId Assignments -SuccessMessage 'Loaded.' -SuppressRender -Operation { + Write-Error 'One workload was unavailable.' + [PSCustomObject]@{ Title = 'Usable result'; Status = 'Available' } + }) + $errorState = New-IACTuiState -InitialView Settings + $errorOutput = @(Invoke-IACTuiCapturedOperation -State $errorState -ViewId Settings -SuccessMessage 'Should not appear.' -SuppressRender -Operation { + Write-Error 'The requested user was not found.' + }) + [PSCustomObject]@{ + WarningRows = @($warningState.Rows.Settings) + WarningStatus = $warningState.StatusStyle + WarningNotices = @($warningState.Notices.Settings) + PartialRows = @($partialState.Rows.Assignments) + PartialNotices = @($partialState.Notices.Assignments) + PartialStatus = $partialState.StatusStyle + PartialOutputCount = $partialOutput.Count + ErrorRows = @($errorState.Rows.Settings) + ErrorStatus = $errorState.StatusStyle + ErrorMessage = $errorState.StatusMessage + ErrorOutputCount = $errorOutput.Count + } + } + + $result.WarningRows.Count | Should -Be 1 + $result.WarningRows[0].Title | Should -BeExactly Result + $result.WarningNotices.Count | Should -Be 1 + $result.WarningStatus | Should -BeExactly Warning + $result.PartialRows.Count | Should -Be 1 + $result.PartialRows[0].Title | Should -BeExactly 'Usable result' + $result.PartialNotices.Count | Should -Be 1 + $result.PartialStatus | Should -BeExactly Warning + $result.PartialOutputCount | Should -Be 1 + $result.ErrorRows[0].Status | Should -BeExactly Error + $result.ErrorStatus | Should -BeExactly Error + $result.ErrorMessage | Should -Match 'not found' + $result.ErrorOutputCount | Should -Be 0 + } + + It 'recognizes legacy host-rendered validation failures as errors' { + $state = & (Get-Module IntuneAssignmentChecker) { + $state = New-IACTuiState -InitialView Simulator + $output = @(Invoke-IACTuiCapturedOperation -State $state -ViewId Simulator -SuccessMessage 'Should not appear.' -SuppressRender -Operation { + Write-Host "Multiple devices match name 'DESKTOP-01'. Use a more specific name." -ForegroundColor Red + Write-Host ' - DESKTOP-01 (ID: device-1, OS: Windows)' + Write-Host ' - DESKTOP-01 (ID: device-2, OS: Windows)' + }) + [PSCustomObject]@{ State = $state; OutputCount = $output.Count } + } + + $state.State.StatusStyle | Should -BeExactly Error + $state.State.Rows.Simulator[0].Status | Should -BeExactly Error + $state.State.Rows.Simulator.Count | Should -Be 3 + $state.State.StatusMessage | Should -Match 'Multiple devices match' + $state.OutputCount | Should -Be 0 } - It 'accepts multiple ValidateSet choices for array parameters' { - Mock Read-Host -ModuleName IntuneAssignmentChecker { 'Core,Audit' } - Mock Write-Host -ModuleName IntuneAssignmentChecker {} - $parameter = [PSCustomObject]@{ - Name = 'Capability'; Type = 'System.String[]'; TypeName = 'String[]'; Mandatory = $false - ValidateSet = @('Core', 'Audit', 'Full'); IsSwitch = $false; IsArray = $true - HelpMessage = $null + It 'keeps progress messages visible without treating them as semantic results' { + $result = & (Get-Module IntuneAssignmentChecker) { + $state = New-IACTuiState -InitialView Drift + $output = @(Invoke-IACTuiCapturedOperation -State $state -ViewId Drift -SuccessMessage 'No drift detected.' -SuppressRender -Operation { + Write-Host '[1/2] Capturing Device configurations...' + Write-Host '[2/2] Capturing Applications...' + }) + [PSCustomObject]@{ State = $state; OutputCount = $output.Count } + } + + $result.OutputCount | Should -Be 0 + $result.State.Rows.Drift.Count | Should -Be 2 + $result.State.RawResults.Drift.Count | Should -Be 0 + $result.State.StatusStyle | Should -BeExactly Success + } + + It 'replaces stale workspace data when an operation terminates' { + $state = & (Get-Module IntuneAssignmentChecker) { + $state = New-IACTuiState -InitialView Assignments + $state.Rows.Assignments = @([PSCustomObject]@{ Title = 'Stale result'; Status = 'Available' }) + $state.RawResults.Assignments = @([PSCustomObject]@{ Id = 'stale' }) + $state.Notices.Assignments = @([PSCustomObject]@{ Status = 'Warning'; Message = 'Stale notice' }) + $null = Invoke-IACTuiCapturedOperation -State $state -ViewId Assignments -SuccessMessage 'Should not appear.' -SuppressRender -Operation { + throw 'The operation terminated.' + } + $state } - $value = & (Get-Module IntuneAssignmentChecker) { - param($Parameter) - Read-IACTuiParameterValue -Parameter $Parameter -CommandName Connect-IntuneAssignmentChecker - } $parameter + $state.Rows.Assignments.Count | Should -Be 1 + $state.Rows.Assignments[0].Title | Should -BeExactly 'Operation failed' + $state.RawResults.Assignments[0].Title | Should -BeExactly 'Operation failed' + $state.Notices.Assignments.Count | Should -Be 1 + $state.Notices.Assignments[0].Message | Should -BeExactly 'The operation terminated.' + $state.StatusStyle | Should -BeExactly Error + } + + It 'keeps overview and drift metrics unknown when their operations fail' { + Mock Test-IACTuiConnected -ModuleName IntuneAssignmentChecker { $true } + Mock Read-IACTuiTextInput -ModuleName IntuneAssignmentChecker { + if ($Prompt -like 'Approved baseline*') { 'baseline.json' } else { 'current.json' } + } + Mock Invoke-IACTuiCapturedOperation -ModuleName IntuneAssignmentChecker { + $State.StatusMessage = 'Graph request failed.' + $State.StatusStyle = 'Error' + @() + } + + $states = & (Get-Module IntuneAssignmentChecker) { + $overview = New-IACTuiState -InitialView Overview + $overview.Metrics.Critical = 0 + $overview.Metrics.Coverage = 'Complete' + Invoke-IACTuiWorkflowAction -State $overview -ActionId RefreshOverview + + $drift = New-IACTuiState -InitialView Drift + $drift.Metrics.Drift = 0 + Invoke-IACTuiWorkflowAction -State $drift -ActionId RefreshDrift + [PSCustomObject]@{ Overview = $overview; Drift = $drift } + } + + $states.Overview.Metrics.Critical | Should -BeNullOrEmpty + $states.Overview.Metrics.Coverage | Should -BeNullOrEmpty + $states.Drift.Metrics.Drift | Should -BeNullOrEmpty + $states.Overview.StatusStyle | Should -BeExactly Error + $states.Drift.StatusStyle | Should -BeExactly Error + } + + It 'exports scan records and preserves incomplete snapshot coverage' { + $path = Join-Path $TestDrive 'tui-scan-snapshot.json' + $snapshot = & (Get-Module IntuneAssignmentChecker) { + param($Path) + $script:CurrentTenantId = 'tenant-1' + $record = New-IACAssignmentRecord -CategoryId Applications -Category Applications ` + -PolicyId app-1 -PolicyName 'Required App' -AssignmentMode Include -TargetType AllUsers + $run = [PSCustomObject]@{ + Records = @($record) + Selected = @('Applications', 'WindowsFeatureUpdates') + Complete = $false + Errors = @([PSCustomObject]@{ CategoryId = 'WindowsFeatureUpdates'; Message = 'Permission denied.' }) + Skipped = @() + } + Export-IACTuiScanRunSnapshot -Run $run -Path $Path + } $path - $value.Supplied | Should -BeTrue - $value.Value | Should -Be @('Core', 'Audit') + $snapshot.Records.Count | Should -Be 1 + $snapshot.Coverage.Complete | Should -BeFalse + ($snapshot.Coverage.Categories | Where-Object CategoryId -EQ WindowsFeatureUpdates).Status | Should -BeExactly Failed + } + + It 'uses Escape as back and reserves Q or Ctrl+C for clean exit' { + $states = & (Get-Module IntuneAssignmentChecker) { + $escapeState = New-IACTuiState -InitialView Assignments + $escapeState.Focus = 'Content' + Invoke-IACTuiInputEvent -State $escapeState -InputEvent (New-IACTuiKeyEvent -Key Escape) -SkipActionInvoke + $quitState = New-IACTuiState + Invoke-IACTuiInputEvent -State $quitState -InputEvent (New-IACTuiKeyEvent -Key Q -Character q) -SkipActionInvoke + $controlState = New-IACTuiState + Invoke-IACTuiInputEvent -State $controlState -InputEvent (New-IACTuiKeyEvent -Key C -Character ([char]3) -Modifiers Control) -SkipActionInvoke + [PSCustomObject]@{ Escape = $escapeState; Quit = $quitState; Control = $controlState } + } + + $states.Escape.ExitRequested | Should -BeFalse + $states.Escape.Focus | Should -BeExactly Navigation + $states.Quit.ExitRequested | Should -BeTrue + $states.Control.ExitRequested | Should -BeTrue + } + + It 'keeps the Settings tenant-switch shortcut available from the keyboard' { + Mock Invoke-IACTuiWorkflowAction -ModuleName IntuneAssignmentChecker {} + $state = & (Get-Module IntuneAssignmentChecker) { + $state = New-IACTuiState -InitialView Settings + $state.Focus = 'Content' + Invoke-IACTuiInputEvent -State $state -InputEvent (New-IACTuiKeyEvent -Key T -Character t) + $navigationState = New-IACTuiState -InitialView Settings + $navigationState.Focus = 'Navigation' + Invoke-IACTuiInputEvent -State $navigationState -InputEvent (New-IACTuiKeyEvent -Key T -Character t) + [PSCustomObject]@{ Content = $state; Navigation = $navigationState } + } + + $state.Content.ActiveViewId | Should -BeExactly Settings + $state.Navigation.ActiveViewId | Should -BeExactly Settings + Should -Invoke Invoke-IACTuiWorkflowAction -ModuleName IntuneAssignmentChecker -Times 2 -ParameterFilter { $ActionId -eq 'SwitchTenant' } + } + + It 'exposes structured non-prompting output on every legacy workflow command' { + foreach ($name in @( + 'Get-IntuneUserDeviceAssignment', 'Get-IntuneEmptyGroup', 'Search-IntuneSetting', + 'Compare-IntuneGroupAssignment', 'Test-IntuneGroupMembership', + 'Test-IntuneGroupRemoval', 'Get-IntuneFailedAssignment' + )) { + (Get-Command $name).Parameters.Keys | Should -Contain PassThru -Because "$name is called inside the command center" + } + } + + It 'keeps legacy one-shot switches non-interactive through PassThru dispatch' { + Mock Connect-IntuneAssignmentChecker -ModuleName IntuneAssignmentChecker {} + Mock Get-MgContext -ModuleName IntuneAssignmentChecker { [PSCustomObject]@{ TenantId = 'tenant-1' } } + Mock Get-IntuneEmptyGroup -ModuleName IntuneAssignmentChecker {} + Mock Compare-IntuneGroupAssignment -ModuleName IntuneAssignmentChecker {} + Mock Get-IntuneFailedAssignment -ModuleName IntuneAssignmentChecker {} + Mock Test-IntuneGroupMembership -ModuleName IntuneAssignmentChecker {} + Mock Test-IntuneGroupRemoval -ModuleName IntuneAssignmentChecker {} + Mock Search-IntuneSetting -ModuleName IntuneAssignmentChecker {} + Mock Get-IntuneUserDeviceAssignment -ModuleName IntuneAssignmentChecker {} + + Invoke-IntuneAssignmentChecker -CheckEmptyGroups + Invoke-IntuneAssignmentChecker -CompareGroups -CompareGroupNames 'A,B' + Invoke-IntuneAssignmentChecker -ShowFailedAssignments + Invoke-IntuneAssignmentChecker -SimulateGroupMembership -UserPrincipalNames 'user@example.test' -SimulateTargetGroup Group + Invoke-IntuneAssignmentChecker -SimulateRemoveFromGroup -UserPrincipalNames 'user@example.test' -SimulateRemoveTargetGroup Group + Invoke-IntuneAssignmentChecker -SearchSetting -SettingKeyword Firewall + Invoke-IntuneAssignmentChecker -CheckUserAndDevice -UserPrincipalNames 'user@example.test' -DeviceNames Device + + Should -Invoke Get-IntuneEmptyGroup -ModuleName IntuneAssignmentChecker -Times 1 -ParameterFilter { $PassThru } + Should -Invoke Compare-IntuneGroupAssignment -ModuleName IntuneAssignmentChecker -Times 1 -ParameterFilter { $PassThru } + Should -Invoke Get-IntuneFailedAssignment -ModuleName IntuneAssignmentChecker -Times 1 -ParameterFilter { $PassThru } + Should -Invoke Test-IntuneGroupMembership -ModuleName IntuneAssignmentChecker -Times 1 -ParameterFilter { $PassThru } + Should -Invoke Test-IntuneGroupRemoval -ModuleName IntuneAssignmentChecker -Times 1 -ParameterFilter { $PassThru } + Should -Invoke Search-IntuneSetting -ModuleName IntuneAssignmentChecker -Times 1 -ParameterFilter { $PassThru } + Should -Invoke Get-IntuneUserDeviceAssignment -ModuleName IntuneAssignmentChecker -Times 1 -ParameterFilter { $PassThru } + } + + It 'opens disconnected and does not force authentication before the UI starts' { + Mock Connect-IntuneAssignmentChecker -ModuleName IntuneAssignmentChecker {} + Mock Start-IntuneAssignmentCheckerTui -ModuleName IntuneAssignmentChecker {} + + Invoke-IntuneAssignmentChecker + + Should -Invoke Connect-IntuneAssignmentChecker -ModuleName IntuneAssignmentChecker -Times 0 + Should -Invoke Start-IntuneAssignmentCheckerTui -ModuleName IntuneAssignmentChecker -Times 1 + } +} + +Describe 'v5 operation metadata API' { + It 'continues to expose structured parameter metadata for automation integrations' { + $operation = Get-IntuneAssignmentOperation -Name Test-IntuneAssignmentGovernance + + $operation.ParameterSets.Count | Should -BeGreaterThan 1 + @($operation.ParameterSets.Parameters.Name) | Should -Contain 'SnapshotPath' + @($operation.ParameterSets.Parameters.Name) | Should -Contain 'FailOnSeverity' } It 'uses concise descriptions and parameter help instead of generated syntax' { diff --git a/examples/IntuneAssignmentChecker-Tui-Concepts.html b/examples/IntuneAssignmentChecker-Tui-Concepts.html new file mode 100644 index 0000000..ee0fd27 --- /dev/null +++ b/examples/IntuneAssignmentChecker-Tui-Concepts.html @@ -0,0 +1,1196 @@ + + + + + + IntuneAssignmentChecker 5.0 — Selected TUI Direction + + + + +
+
+
+
IntuneAssignmentChecker 5.0 · Selected TUI direction
+

Command Center. Workbench palette.

+
+

Draft A is now the selected structure. It uses Draft B’s navy, amber, blue, and slate color system while keeping the dashboard-first operational workflow.

+
+ +
+ + +
+ +
+
+
+
>_PowerShell 7
+
IntuneAssignmentChecker 5.0
+
×
+
+ +
+ + +
+
+
Workspace Overview
+
+
+ +
+
+
+

Assignment posture

What requires attention across this tenant right now.

+
SCAN 2026-08-02 08:14:26Z · 1,842 ASSIGNMENTS
+
+ +
+
Scan coverage
14/14
All workload providers complete
+
Critical findings
3
+1 since approved baseline
+
Unapproved drift
7
2 changes attributed
+
Delivery healthy
91.4%
226 failures · 41 stale
+
+ +
+
+
Priority queueEnter to investigate
+
CRITICALWindows Security BaselineRequired on All Devices without an exclusion8m
+
CRITICALMicrosoft 365 AppsRequired on All Users; no staged rollout21m
+
HIGHBitLocker ComplianceAssignment filter result is unknown1h
+
HIGHEDR — Windows43 devices excluded by overlapping groups2h
+
+ +
+
Delivery by workloadLast 14 days
+
+
Device configuration96%
+
Compliance88%
+
Applications90%
+
✓ 8,441 successful   × 226 failed
~ 41 stale   — 19 not applicable
+
+
+ +
+
Recent assignment driftApproved baseline · 2026-08-01
+
08:02:11TARGET ADDEDMicrosoft 365 AppsAll UsersHIGH
+
07:48:37FILTER CHANGEDWindows Update Ring — BroadDeviceOwnershipMED
+
06:15:09EXCLUSION REMOVEDWindows Security BaselinePilot DevicesCRIT
+
+
+
+ +
+
+

Governance findings

Evidence, impact, remediation, and approved exceptions in one workflow.

+
12 OPEN · 4 WAIVED · 2 NEW
+
+
+
+
RULES 10/10
+
SEVERITYRULEPOLICYTARGET
+
CRITICALIAC-GOV-001Windows Security BaselineAll Devices
+
CRITICALIAC-GOV-002Microsoft 365 AppsAll Users
+
HIGHIAC-GOV-004EDR — WindowsCorporate Windows
+
HIGHIAC-GOV-007BitLocker ComplianceFinance Devices
+
MEDIUMIAC-GOV-009Edge Security SettingsAll Users
+
+ +
+
+ +
+
+

Assignments

Browse targeting state without knowing which cmdlet provides it.

+
TASK-ORIENTED WORKFLOW
+
+
+
+
Browse and inspect
+
+
SELECTEDWindows Security BaselineOPEN
+
RELATEDCorporate Windows Devices1,284
+
RELATEDPrivileged Device Exclusions42
+
STATUSCoverage and evidence availableREADY
+
+
+ +
+
+
+ +
↑↓navigateEnteropen/searchSsimulateEexportTtenant?help
+
+
+ + +
+
+ +
+ Selected A+B + Draft A’s monitoring-oriented Command Center, now using Draft B’s restrained navy surfaces and amber interaction color. + Dashboard structure · Workbench palette +
+
+ + + + diff --git a/packaging/New-WinGetManifest.ps1 b/packaging/New-WinGetManifest.ps1 index 1898626..a360eed 100644 --- a/packaging/New-WinGetManifest.ps1 +++ b/packaging/New-WinGetManifest.ps1 @@ -78,8 +78,8 @@ PackageName: Intune Assignment Checker PackageUrl: https://github.com/ugurkocde/IntuneAssignmentChecker License: MIT LicenseUrl: https://github.com/ugurkocde/IntuneAssignmentChecker/blob/v$Version/LICENSE -ShortDescription: Audit, simulate, and govern Microsoft Intune assignments from PowerShell or its terminal UI. -Description: A PowerShell-native, read-only assignment governance platform for Microsoft Intune with a full-parity terminal UI, snapshots, drift analysis, change simulation, delivery health, and multi-tenant scans. +ShortDescription: Audit, simulate, and govern Microsoft Intune assignments from PowerShell or its terminal command center. +Description: A PowerShell-native, read-only assignment governance platform for Microsoft Intune with a task-oriented mouse and keyboard terminal UI, snapshots, drift analysis, change simulation, delivery health, and multi-tenant scans. Moniker: intune-assignment-checker Tags: - intune From b7afa33b98f3341f502032009d60cd80e5dae0c7 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:59:44 +0200 Subject: [PATCH 08/13] test: pin nonterminating error scenarios --- Tests/Unit/V5Platform.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Unit/V5Platform.Tests.ps1 b/Tests/Unit/V5Platform.Tests.ps1 index 084c513..a36e539 100644 --- a/Tests/Unit/V5Platform.Tests.ps1 +++ b/Tests/Unit/V5Platform.Tests.ps1 @@ -209,12 +209,12 @@ Describe 'v5 task-oriented terminal UI' { } $partialState = New-IACTuiState -InitialView Assignments $partialOutput = @(Invoke-IACTuiCapturedOperation -State $partialState -ViewId Assignments -SuccessMessage 'Loaded.' -SuppressRender -Operation { - Write-Error 'One workload was unavailable.' + Write-Error 'One workload was unavailable.' -ErrorAction Continue [PSCustomObject]@{ Title = 'Usable result'; Status = 'Available' } }) $errorState = New-IACTuiState -InitialView Settings $errorOutput = @(Invoke-IACTuiCapturedOperation -State $errorState -ViewId Settings -SuccessMessage 'Should not appear.' -SuppressRender -Operation { - Write-Error 'The requested user was not found.' + Write-Error 'The requested user was not found.' -ErrorAction Continue }) [PSCustomObject]@{ WarningRows = @($warningState.Rows.Settings) From f5cf6b646b9b51684351bc9cf7a0d6fa2a363a13 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:07:32 +0200 Subject: [PATCH 09/13] feat: add PowerShell 7 compatibility launcher --- .github/workflows/windows-package.yml | 28 +++++++++++ .../IntuneAssignmentChecker.psd1 | 1 + README.md | 10 ++-- Tests/Release/ModulePackage.Tests.ps1 | 37 ++++++++++++++ packaging/Build-WindowsInstaller.ps1 | 20 ++++++-- packaging/IntuneAssignmentChecker.cmd | 50 +++++++++++++++++++ packaging/IntuneAssignmentChecker.wxs | 17 +++++++ packaging/New-WinGetManifest.ps1 | 3 ++ packaging/README.md | 9 ++++ 9 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 packaging/IntuneAssignmentChecker.cmd diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 2bb42ec..c247af7 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -85,6 +85,9 @@ jobs: $installLog = Join-Path $env:RUNNER_TEMP 'intune-assignment-checker-install.log' $removeLog = Join-Path $env:RUNNER_TEMP 'intune-assignment-checker-remove.log' $verificationScript = Join-Path $env:RUNNER_TEMP 'verify-intune-assignment-checker.ps1' + $commandFolder = Join-Path $env:ProgramFiles 'Intune Assignment Checker\bin' + $normalizedCommandFolder = $commandFolder.TrimEnd([IO.Path]::DirectorySeparatorChar) + $launcherPath = Join-Path $commandFolder 'IntuneAssignmentChecker.cmd' $installed = $false try { Write-Host "Installing $msi" @@ -103,6 +106,22 @@ jobs: Where-Object Version -EQ '${{ steps.package.outputs.version }}') $available | Format-List Name, Version, Path if ($available.Count -eq 0) { throw 'The installed module was not discoverable through PSModulePath.' } + if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) { + throw "The PowerShell 7 command launcher was not installed at '$launcherPath'." + } + $machinePath = [Environment]::GetEnvironmentVariable('PATH', 'Machine') + $machinePathEntries = @($machinePath -split ';' | ForEach-Object { $_.Trim().TrimEnd([IO.Path]::DirectorySeparatorChar) }) + if ($machinePathEntries -notcontains $normalizedCommandFolder) { + throw "The command launcher directory was not added to the machine PATH: '$commandFolder'." + } + $env:IAC_LAUNCHER_PATH = $launcherPath + $launcherOutput = @(& $env:ComSpec /d /s /c 'call "%IAC_LAUNCHER_PATH%" --check' 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "The installed command launcher check failed with exit code $LASTEXITCODE: $($launcherOutput -join ' ')" + } + if (($launcherOutput -join [Environment]::NewLine) -notmatch 'is ready in PowerShell 7') { + throw "The installed command launcher did not confirm a PowerShell 7 handoff: $($launcherOutput -join ' ')" + } @' param([Parameter(Mandatory)][string]$Version) @@ -140,6 +159,7 @@ jobs: } } finally { + Remove-Item Env:IAC_LAUNCHER_PATH -ErrorAction SilentlyContinue Remove-Item -LiteralPath $verificationScript -Force -ErrorAction SilentlyContinue if ($installed) { $remove = Start-Process msiexec.exe -ArgumentList @( @@ -151,6 +171,14 @@ jobs: Get-Content -LiteralPath $removeLog -Tail 200 -ErrorAction SilentlyContinue throw "MSI removal failed with exit code $removeExitCode." } + if (Test-Path -LiteralPath $launcherPath) { + throw "The command launcher remained after MSI removal: '$launcherPath'." + } + $machinePathAfterRemoval = [Environment]::GetEnvironmentVariable('PATH', 'Machine') + $machinePathEntriesAfterRemoval = @($machinePathAfterRemoval -split ';' | ForEach-Object { $_.Trim().TrimEnd([IO.Path]::DirectorySeparatorChar) }) + if ($machinePathEntriesAfterRemoval -contains $normalizedCommandFolder) { + throw "The command launcher directory remained in the machine PATH after removal: '$commandFolder'." + } } } diff --git a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 index 57c021d..ae6ea66 100644 --- a/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 +++ b/Module/IntuneAssignmentChecker/IntuneAssignmentChecker.psd1 @@ -69,6 +69,7 @@ ReleaseNotes = @' Version 5.0.0: - Add a mouse- and keyboard-enabled PowerShell terminal command center with native workspaces for every exported module capability. +- Add a non-executable Windows command launcher that hands PowerShell 5.1 and Command Prompt users off to the same module in PowerShell 7. - Add structured -PassThru results to the remaining legacy assignment, simulation, failure, comparison, empty-group, and setting-search commands. - Add assignment governance, change simulation, drift attribution, fleet orchestration, delivery health, RBAC analysis, filter-set governance, capability-based authentication, and environment diagnostics. - Add schema-governed structured output, MSI packaging, and WinGet release automation without converting the module to an executable. diff --git a/README.md b/README.md index 88b3c1d..b32925a 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,16 @@ ```powershell winget install --id UgurKoc.IntuneAssignmentChecker --exact -# Open PowerShell 7, then launch the command center -pwsh -Start-IntuneAssignmentCheckerTui +# Open a new terminal, then launch the command center from any Windows shell +IntuneAssignmentChecker ``` The WinGet package is an MSI that installs the PowerShell module and its Graph authentication dependency. It does not install or generate an executable version -of IntuneAssignmentChecker. +of IntuneAssignmentChecker. Its small command launcher hands off immediately to +PowerShell 7, so the same command also works from Windows PowerShell 5.1. Use +`IntuneAssignmentChecker --disable-mouse` when the terminal should retain mouse +events. ### Option 2: Install from PowerShell Gallery diff --git a/Tests/Release/ModulePackage.Tests.ps1 b/Tests/Release/ModulePackage.Tests.ps1 index cfef2ef..489834d 100644 --- a/Tests/Release/ModulePackage.Tests.ps1 +++ b/Tests/Release/ModulePackage.Tests.ps1 @@ -74,4 +74,41 @@ Describe 'IntuneAssignmentChecker release package' { @(Get-ChildItem $repoRoot -Recurse -File -Include '*.csproj', '*.cs', '*.exe').Count | Should -Be 0 (Get-Content (Join-Path $repoRoot 'packaging/README.md') -Raw) | Should -Match 'does not compile or wrap' } + + It 'ships a PowerShell 7 command handoff without duplicating application logic' { + $launcherPath = Join-Path $repoRoot 'packaging/IntuneAssignmentChecker.cmd' + Test-Path -LiteralPath $launcherPath -PathType Leaf | Should -BeTrue + $launcher = Get-Content -LiteralPath $launcherPath -Raw + $launcher | Should -Match 'pwsh\.exe' + $launcher | Should -Match 'Start-IntuneAssignmentCheckerTui' + $launcher | Should -Match 'requires PowerShell 7' + $launcher | Should -Not -Match '(?i)powershell\.exe' + + $wix = Get-Content (Join-Path $repoRoot 'packaging/IntuneAssignmentChecker.wxs') -Raw + $wix | Should -Match 'LauncherSource' + $wix | Should -Match 'Name="PATH"' + $wix | Should -Match 'System="yes"' + + $buildScript = Get-Content (Join-Path $repoRoot 'packaging/Build-WindowsInstaller.ps1') -Raw + $buildScript | Should -Match 'launcherStagingRoot' + $buildScript | Should -Match 'Replace\("`r`n", "`n"\)' + $buildScript | Should -Match 'UTF8Encoding.*false' + } + + It 'publishes the launch command and PowerShell 5.1 guidance in WinGet metadata' { + $fakeInstaller = Join-Path $TestDrive 'IntuneAssignmentChecker-5.0.0-x64.msi' + Set-Content -LiteralPath $fakeInstaller -Value 'test installer' -NoNewline + $outputDirectory = Join-Path $TestDrive 'winget' + & (Join-Path $repoRoot 'packaging/New-WinGetManifest.ps1') ` + -InstallerPath $fakeInstaller ` + -InstallerUrl 'https://example.test/IntuneAssignmentChecker-5.0.0-x64.msi' ` + -ProductCode '{B7F62E8A-5838-4EBB-9EE0-2C3E1B36AE32}' ` + -OutputDirectory $outputDirectory | Out-Null + + $installerManifest = Get-Content (Join-Path $outputDirectory 'UgurKoc.IntuneAssignmentChecker.installer.yaml') -Raw + $localeManifest = Get-Content (Join-Path $outputDirectory 'UgurKoc.IntuneAssignmentChecker.locale.en-US.yaml') -Raw + $installerManifest | Should -Match '(?m)^Commands:\s*\r?\n- IntuneAssignmentChecker$' + $installerManifest | Should -Match 'PackageIdentifier: Microsoft\.PowerShell' + $localeManifest | Should -Match '(?m)^InstallationNotes:.*PowerShell 5\.1\.$' + } } diff --git a/packaging/Build-WindowsInstaller.ps1 b/packaging/Build-WindowsInstaller.ps1 index a6afc2d..73d738e 100644 --- a/packaging/Build-WindowsInstaller.ps1 +++ b/packaging/Build-WindowsInstaller.ps1 @@ -48,23 +48,35 @@ New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null $stagingRoot = Join-Path $resolvedOutput 'windows-package-staging' if (Test-Path -LiteralPath $stagingRoot) { Remove-Item -LiteralPath $stagingRoot -Recurse -Force } New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null +$moduleStagingRoot = Join-Path $stagingRoot 'modules' +$launcherStagingRoot = Join-Path $stagingRoot 'launcher' +New-Item -ItemType Directory -Path $moduleStagingRoot -Force | Out-Null +New-Item -ItemType Directory -Path $launcherStagingRoot -Force | Out-Null -$moduleDestination = Join-Path $stagingRoot "IntuneAssignmentChecker/$Version" +$moduleDestination = Join-Path $moduleStagingRoot "IntuneAssignmentChecker/$Version" New-Item -ItemType Directory -Path $moduleDestination -Force | Out-Null Copy-Item -Path (Join-Path $moduleSource '*') -Destination $moduleDestination -Recurse -Force if (-not $SkipDependencyDownload) { Save-Module -Name Microsoft.Graph.Authentication -RequiredVersion $GraphAuthenticationVersion ` - -Repository PSGallery -Path $stagingRoot -Force -ErrorAction Stop + -Repository PSGallery -Path $moduleStagingRoot -Force -ErrorAction Stop } -elseif (-not (Test-Path -LiteralPath (Join-Path $stagingRoot 'Microsoft.Graph.Authentication'))) { +elseif (-not (Test-Path -LiteralPath (Join-Path $moduleStagingRoot 'Microsoft.Graph.Authentication'))) { Write-Warning 'Microsoft.Graph.Authentication was not staged because -SkipDependencyDownload was used.' } +# cmd.exe parsing is sensitive to batch-file line endings. Normalize the MSI +# payload independently of the maintainer's checkout platform or Git settings. +$launcherSource = Join-Path $PSScriptRoot 'IntuneAssignmentChecker.cmd' +$launcherDestination = Join-Path $launcherStagingRoot 'IntuneAssignmentChecker.cmd' +$launcherText = [IO.File]::ReadAllText($launcherSource) +$launcherText = $launcherText.Replace("`r`n", "`n").Replace("`r", "`n").Replace("`n", "`r`n") +[IO.File]::WriteAllText($launcherDestination, $launcherText, [Text.UTF8Encoding]::new($false)) + $outputPath = Join-Path $resolvedOutput "IntuneAssignmentChecker-$Version-x64.msi" $wixOutput = @(& wix build (Join-Path $PSScriptRoot 'IntuneAssignmentChecker.wxs') -arch x64 ` -d "ProductVersion=$Version" -d "ProductCode=$productCode" ` - -bindpath "ModuleSource=$stagingRoot" -o $outputPath 2>&1) + -bindpath "ModuleSource=$moduleStagingRoot" -bindpath "LauncherSource=$launcherStagingRoot" -o $outputPath 2>&1) $wixExitCode = $LASTEXITCODE $wixOutput | ForEach-Object { Write-Host $_ } if ($wixExitCode -ne 0 -or -not (Test-Path -LiteralPath $outputPath -PathType Leaf)) { diff --git a/packaging/IntuneAssignmentChecker.cmd b/packaging/IntuneAssignmentChecker.cmd new file mode 100644 index 0000000..139d91b --- /dev/null +++ b/packaging/IntuneAssignmentChecker.cmd @@ -0,0 +1,50 @@ +@echo off +setlocal + +set "IAC_PWSH=" +if defined ProgramW6432 if exist "%ProgramW6432%\PowerShell\7\pwsh.exe" set "IAC_PWSH=%ProgramW6432%\PowerShell\7\pwsh.exe" +if not defined IAC_PWSH if exist "%ProgramFiles%\PowerShell\7\pwsh.exe" set "IAC_PWSH=%ProgramFiles%\PowerShell\7\pwsh.exe" +if not defined IAC_PWSH for /f "delims=" %%P in ('where pwsh.exe 2^>nul') do if not defined IAC_PWSH set "IAC_PWSH=%%P" +if not defined IAC_PWSH goto powershell_not_found + +if "%~1"=="" goto launch +if not "%~2"=="" goto usage +if /I "%~1"=="--disable-mouse" goto launch_without_mouse +if /I "%~1"=="--check" goto check +goto usage + +:launch +echo Intune Assignment Checker requires PowerShell 7. +echo Starting it now... +echo. +"%IAC_PWSH%" -NoLogo -NoProfile -Command "Import-Module IntuneAssignmentChecker -ErrorAction Stop; Start-IntuneAssignmentCheckerTui" +goto finish + +:launch_without_mouse +echo Intune Assignment Checker requires PowerShell 7. +echo Starting it now with terminal mouse reporting disabled... +echo. +"%IAC_PWSH%" -NoLogo -NoProfile -Command "Import-Module IntuneAssignmentChecker -ErrorAction Stop; Start-IntuneAssignmentCheckerTui -DisableMouse" +goto finish + +:check +"%IAC_PWSH%" -NoLogo -NoProfile -Command "$required = [version]'7.0'; if ($PSVersionTable.PSVersion -lt $required) { Write-Error 'Intune Assignment Checker requires PowerShell 7 or newer.'; exit 1 }; Import-Module IntuneAssignmentChecker -ErrorAction Stop; $module = Get-Module IntuneAssignmentChecker; Write-Output ('Intune Assignment Checker {0} is ready in PowerShell {1}.' -f $module.Version, $PSVersionTable.PSVersion)" +goto finish + +:powershell_not_found +echo Intune Assignment Checker requires PowerShell 7, but pwsh.exe was not found. +echo Install it with: +echo winget install --id Microsoft.PowerShell --exact +exit /b 1 + +:usage +echo Usage: IntuneAssignmentChecker [--disable-mouse ^| --check] +exit /b 2 + +:finish +set "IAC_EXIT_CODE=%ERRORLEVEL%" +if not "%IAC_EXIT_CODE%"=="0" ( + echo. + echo Intune Assignment Checker closed with exit code %IAC_EXIT_CODE%. +) +exit /b %IAC_EXIT_CODE% diff --git a/packaging/IntuneAssignmentChecker.wxs b/packaging/IntuneAssignmentChecker.wxs index 8d159f9..38e059e 100644 --- a/packaging/IntuneAssignmentChecker.wxs +++ b/packaging/IntuneAssignmentChecker.wxs @@ -16,10 +16,14 @@ + + + + @@ -28,4 +32,17 @@ + + + + + + + diff --git a/packaging/New-WinGetManifest.ps1 b/packaging/New-WinGetManifest.ps1 index a360eed..06b4cba 100644 --- a/packaging/New-WinGetManifest.ps1 +++ b/packaging/New-WinGetManifest.ps1 @@ -46,6 +46,8 @@ InstallModes: - silent - silentWithProgress UpgradeBehavior: install +Commands: +- IntuneAssignmentChecker ReleaseDate: $([datetime]::UtcNow.ToString('yyyy-MM-dd')) Dependencies: PackageDependencies: @@ -80,6 +82,7 @@ License: MIT LicenseUrl: https://github.com/ugurkocde/IntuneAssignmentChecker/blob/v$Version/LICENSE ShortDescription: Audit, simulate, and govern Microsoft Intune assignments from PowerShell or its terminal command center. Description: A PowerShell-native, read-only assignment governance platform for Microsoft Intune with a task-oriented mouse and keyboard terminal UI, snapshots, drift analysis, change simulation, delivery health, and multi-tenant scans. +InstallationNotes: Open a new terminal after installation and run IntuneAssignmentChecker. The launcher automatically uses PowerShell 7, including when it is called from Windows PowerShell 5.1. Moniker: intune-assignment-checker Tags: - intune diff --git a/packaging/README.md b/packaging/README.md index 44d8510..f5cba73 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -5,6 +5,15 @@ the exact module source plus the pinned `Microsoft.Graph.Authentication` runtime dependency into `C:\Program Files\PowerShell\Modules`. It does not compile or wrap the module as an executable. +The MSI also installs a small `IntuneAssignmentChecker.cmd` handoff in +`C:\Program Files\Intune Assignment Checker\bin` and adds that directory to the +system `PATH`. The handoff contains no application logic: it starts PowerShell 7 +and invokes `Start-IntuneAssignmentCheckerTui` from the installed module. This lets +someone type `IntuneAssignmentChecker` from Windows PowerShell 5.1, Command Prompt, +or a fresh PowerShell 7 session without receiving the module-manifest compatibility +error. A new terminal is required after the first installation so it inherits the +updated `PATH`. + Build on Windows with PowerShell 7, the .NET SDK, and WiX 6.0.2: ```powershell From ac38e61c1f6dc986d22eac43aa2388bcdd10c9ef Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:09:15 +0200 Subject: [PATCH 10/13] ci: fix launcher check diagnostics --- .github/workflows/windows-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index c247af7..4ba6b2b 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -117,7 +117,7 @@ jobs: $env:IAC_LAUNCHER_PATH = $launcherPath $launcherOutput = @(& $env:ComSpec /d /s /c 'call "%IAC_LAUNCHER_PATH%" --check' 2>&1) if ($LASTEXITCODE -ne 0) { - throw "The installed command launcher check failed with exit code $LASTEXITCODE: $($launcherOutput -join ' ')" + throw "The installed command launcher check failed with exit code ${LASTEXITCODE}: $($launcherOutput -join ' ')" } if (($launcherOutput -join [Environment]::NewLine) -notmatch 'is ready in PowerShell 7') { throw "The installed command launcher did not confirm a PowerShell 7 handoff: $($launcherOutput -join ' ')" From eb945c0e3459210a8db34f92cc1c6ae3970a41aa Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:52:07 +0200 Subject: [PATCH 11/13] ci: add MCP parity gate --- .gitattributes | 1 + .github/workflows/mcp-parity.yml | 88 +++++ Tests/Parity/Export-McpParityFixtures.ps1 | 396 +++++++++++++++++++ Tests/Parity/assignment-parity.v1.json | 442 ++++++++++++++++++++++ Tests/README.md | 13 + Tests/Unit/McpParityFixtures.Tests.ps1 | 52 +++ 6 files changed, 992 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/mcp-parity.yml create mode 100644 Tests/Parity/Export-McpParityFixtures.ps1 create mode 100644 Tests/Parity/assignment-parity.v1.json create mode 100644 Tests/Unit/McpParityFixtures.Tests.ps1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3811a31 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +Tests/Parity/assignment-parity.v1.json text eol=lf diff --git a/.github/workflows/mcp-parity.yml b/.github/workflows/mcp-parity.yml new file mode 100644 index 0000000..7b6d7af --- /dev/null +++ b/.github/workflows/mcp-parity.yml @@ -0,0 +1,88 @@ +name: MCP parity gate + +on: + push: + branches: [main] + paths: + - "Module/**" + - "Tests/Parity/**" + - "Tests/Unit/McpParityFixtures.Tests.ps1" + - ".github/workflows/mcp-parity.yml" + pull_request: + branches: [main] + paths: + - "Module/**" + - "Tests/Parity/**" + - "Tests/Unit/McpParityFixtures.Tests.ps1" + - ".github/workflows/mcp-parity.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + fixture: + name: Verify deterministic PowerShell fixture + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout PowerShell change + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: powershell-reference + persist-credentials: false + + - name: Verify the committed PowerShell fixture is deterministic + shell: pwsh + run: | + $generated = Join-Path $env:RUNNER_TEMP 'assignment-parity.v1.json' + ./powershell-reference/Tests/Parity/Export-McpParityFixtures.ps1 -OutputPath $generated | Out-Null + $committed = './powershell-reference/Tests/Parity/assignment-parity.v1.json' + $generatedContent = [System.IO.File]::ReadAllText($generated).Replace("`r`n", "`n") + $committedContent = [System.IO.File]::ReadAllText($committed).Replace("`r`n", "`n") + if ($generatedContent -cne $committedContent) { + throw 'PowerShell behavior changed without regenerating the MCP parity fixture.' + } + + parity: + name: Verify TypeScript MCP compatibility + needs: fixture + if: ${{ vars.IAC_MCP_REPOSITORY != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout PowerShell change + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: powershell-reference + persist-credentials: false + + - name: Checkout MCP implementation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ vars.IAC_MCP_REPOSITORY }} + path: mcp + token: ${{ secrets.IAC_MCP_REPOSITORY_TOKEN || github.token }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: mcp/package-lock.json + + - name: Install MCP dependencies + working-directory: mcp + run: npm ci + + - name: Test the MCP against this PowerShell change + working-directory: mcp + env: + IAC_POWERSHELL_REPO: ${{ github.workspace }}/powershell-reference + run: | + npm run parity:sync + npm run check + npm run build diff --git a/Tests/Parity/Export-McpParityFixtures.ps1 b/Tests/Parity/Export-McpParityFixtures.ps1 new file mode 100644 index 0000000..5988514 --- /dev/null +++ b/Tests/Parity/Export-McpParityFixtures.ps1 @@ -0,0 +1,396 @@ +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [Parameter()] + [string]$OutputPath = (Join-Path $PSScriptRoot 'assignment-parity.v1.json') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$modulePrivate = Join-Path $PSScriptRoot '../../Module/IntuneAssignmentChecker/Private' +foreach ($fileName in @( + 'Get-PolicyPlatform.ps1' + 'Get-ScopeTagNames.ps1' + 'New-IACAssignmentRecord.ps1' + 'ConvertTo-IACAssignmentRecord.ps1' + 'ConvertTo-IACNormalizedAssignment.ps1' + )) { + . (Join-Path $modulePrivate $fileName) +} + +$script:CurrentTenantId = '00000000-0000-0000-0000-000000000001' +$script:CurrentTenantName = 'Parity Fixture Tenant' +$script:ScopeTagLookup = @{ + '0' = 'Default' + '7' = 'Security' +} +$script:AssignmentFilterLookup = @{} +$script:ParityGroupLookup = @{} + +function Get-GroupInfo { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$GroupId) + + [PSCustomObject]@{ + Id = $GroupId + DisplayName = $script:ParityGroupLookup[$GroupId] + } +} + +$categories = @{ + configurationPolicy = [PSCustomObject][ordered]@{ + Id = 'configurationPolicy' + DisplayName = 'Settings Catalog and Endpoint Security' + ExportCategory = 'Settings Catalog and Endpoint Security' + Platform = 'Windows' + } + deviceConfiguration = [PSCustomObject][ordered]@{ + Id = 'deviceConfiguration' + DisplayName = 'Device Configuration' + ExportCategory = 'Device Configuration' + Platform = $null + } + compliancePolicy = [PSCustomObject][ordered]@{ + Id = 'compliancePolicy' + DisplayName = 'Compliance Policy' + ExportCategory = 'Compliance Policy' + Platform = $null + } + application = [PSCustomObject][ordered]@{ + Id = 'application' + DisplayName = 'Application' + ExportCategory = 'Application' + Platform = $null + } + appConfiguration = [PSCustomObject][ordered]@{ + Id = 'appConfiguration' + DisplayName = 'App Configuration Policy' + ExportCategory = 'App Configuration Policy' + Platform = $null + } +} + +$cases = @( + [PSCustomObject][ordered]@{ + Name = 'configuration group include with assignment filter' + Category = 'configurationPolicy' + Policy = [PSCustomObject][ordered]@{ + id = '11111111-1111-1111-1111-111111111111' + name = 'Windows security baseline' + displayName = $null + platforms = 'windows10' + technologies = 'mdm' + roleScopeTagIds = @('0', '7') + } + Assignment = [PSCustomObject][ordered]@{ + id = 'assignment-group-include' + intent = $null + target = [PSCustomObject][ordered]@{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = '22222222-2222-2222-2222-222222222222' + deviceAndAppManagementAssignmentFilterId = '33333333-3333-3333-3333-333333333333' + deviceAndAppManagementAssignmentFilterType = 'include' + } + } + Groups = @( + [PSCustomObject][ordered]@{ + id = '22222222-2222-2222-2222-222222222222' + displayName = 'Pilot Devices' + } + ) + Filters = @( + [PSCustomObject][ordered]@{ + id = '33333333-3333-3333-3333-333333333333' + displayName = 'Corporate Windows' + platform = 'windows10AndLater' + rule = '(device.deviceOwnership -eq "Corporate")' + } + ) + } + [PSCustomObject][ordered]@{ + Name = 'configuration group exclusion' + Category = 'configurationPolicy' + Policy = [PSCustomObject][ordered]@{ + id = '44444444-4444-4444-4444-444444444444' + name = 'Windows update ring' + displayName = $null + platforms = 'windows10' + technologies = 'mdm' + roleScopeTagIds = @('0') + } + Assignment = [PSCustomObject][ordered]@{ + id = 'assignment-group-exclude' + intent = $null + target = [PSCustomObject][ordered]@{ + '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget' + groupId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + deviceAndAppManagementAssignmentFilterId = $null + deviceAndAppManagementAssignmentFilterType = 'none' + } + } + Groups = @( + [PSCustomObject][ordered]@{ + id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + displayName = 'Excluded Devices' + } + ) + Filters = @() + } + [PSCustomObject][ordered]@{ + Name = 'all licensed users target' + Category = 'configurationPolicy' + Policy = [PSCustomObject][ordered]@{ + id = '55555555-5555-5555-5555-555555555555' + name = 'User certificate policy' + displayName = $null + platforms = 'windows10' + technologies = 'mdm' + roleScopeTagIds = @() + } + Assignment = [PSCustomObject][ordered]@{ + id = 'assignment-all-users' + intent = $null + target = [PSCustomObject][ordered]@{ + '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' + deviceAndAppManagementAssignmentFilterId = '33333333-3333-3333-3333-333333333333' + deviceAndAppManagementAssignmentFilterType = 'none' + } + } + Groups = @() + Filters = @( + [PSCustomObject][ordered]@{ + id = '33333333-3333-3333-3333-333333333333' + displayName = 'Corporate Windows' + platform = 'windows10AndLater' + rule = '(device.deviceOwnership -eq "Corporate")' + } + ) + } + [PSCustomObject][ordered]@{ + Name = 'all devices target suppresses empty filter sentinel' + Category = 'configurationPolicy' + Policy = [PSCustomObject][ordered]@{ + id = '66666666-6666-6666-6666-666666666666' + name = 'Device restrictions' + displayName = $null + platforms = 'windows10' + technologies = 'mdm' + roleScopeTagIds = @('7') + } + Assignment = [PSCustomObject][ordered]@{ + id = 'assignment-all-devices' + intent = $null + target = [PSCustomObject][ordered]@{ + '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' + deviceAndAppManagementAssignmentFilterId = '00000000-0000-0000-0000-000000000000' + deviceAndAppManagementAssignmentFilterType = 'include' + } + } + Groups = @() + Filters = @() + } + [PSCustomObject][ordered]@{ + Name = 'mobile application assignment intent and platform' + Category = 'application' + Policy = [PSCustomObject][ordered]@{ + id = '77777777-7777-7777-7777-777777777777' + name = $null + displayName = 'Contoso Agent' + '@odata.type' = '#microsoft.graph.win32LobApp' + roleScopeTagIds = @('0') + isAssigned = $true + } + Assignment = [PSCustomObject][ordered]@{ + id = 'assignment-app-required' + intent = 'required' + source = 'direct' + target = [PSCustomObject][ordered]@{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = '22222222-2222-2222-2222-222222222222' + deviceAndAppManagementAssignmentFilterId = $null + deviceAndAppManagementAssignmentFilterType = 'none' + } + } + Groups = @( + [PSCustomObject][ordered]@{ + id = '22222222-2222-2222-2222-222222222222' + displayName = 'Pilot Devices' + } + ) + Filters = @() + } + [PSCustomObject][ordered]@{ + Name = 'device configuration with unresolved group name' + Category = 'deviceConfiguration' + Policy = [PSCustomObject][ordered]@{ + id = '88888888-8888-8888-8888-888888888888' + name = $null + displayName = 'Legacy device restrictions' + '@odata.type' = '#microsoft.graph.windows10GeneralConfiguration' + roleScopeTagIds = @('7') + } + Assignment = [PSCustomObject][ordered]@{ + id = 'assignment-unresolved-group' + intent = $null + target = [PSCustomObject][ordered]@{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' + deviceAndAppManagementAssignmentFilterId = $null + deviceAndAppManagementAssignmentFilterType = 'none' + } + } + Groups = @() + Filters = @() + } + [PSCustomObject][ordered]@{ + Name = 'Android compliance assignment with exclude filter' + Category = 'compliancePolicy' + Policy = [PSCustomObject][ordered]@{ + id = '99999999-9999-9999-9999-999999999999' + name = $null + displayName = 'Corporate Android compliance' + '@odata.type' = '#microsoft.graph.androidDeviceOwnerCompliancePolicy' + roleScopeTagIds = @('0') + } + Assignment = [PSCustomObject][ordered]@{ + id = 'assignment-compliance-filter-exclude' + intent = $null + target = [PSCustomObject][ordered]@{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = '22222222-2222-2222-2222-222222222222' + deviceAndAppManagementAssignmentFilterId = 'cccccccc-cccc-cccc-cccc-cccccccccccc' + deviceAndAppManagementAssignmentFilterType = 'exclude' + } + } + Groups = @( + [PSCustomObject][ordered]@{ + id = '22222222-2222-2222-2222-222222222222' + displayName = 'Pilot Devices' + } + ) + Filters = @( + [PSCustomObject][ordered]@{ + id = 'cccccccc-cccc-cccc-cccc-cccccccccccc' + displayName = 'Personally owned Android' + platform = 'androidForWork' + rule = '(device.deviceOwnership -eq "Personal")' + } + ) + } + [PSCustomObject][ordered]@{ + Name = 'Android app configuration assigned to all devices' + Category = 'appConfiguration' + Policy = [PSCustomObject][ordered]@{ + id = 'dddddddd-dddd-dddd-dddd-dddddddddddd' + name = $null + displayName = 'Managed browser configuration' + '@odata.type' = '#microsoft.graph.androidManagedStoreAppConfiguration' + roleScopeTagIds = @() + targetedMobileApps = @('77777777-7777-7777-7777-777777777777') + } + Assignment = [PSCustomObject][ordered]@{ + id = 'assignment-app-configuration-all-devices' + intent = $null + source = 'direct' + target = [PSCustomObject][ordered]@{ + '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' + deviceAndAppManagementAssignmentFilterId = $null + deviceAndAppManagementAssignmentFilterType = 'none' + } + } + Groups = @() + Filters = @() + } +) + +foreach ($case in $cases) { + foreach ($group in @($case.Groups)) { + $script:ParityGroupLookup["$($group.id)"] = "$($group.displayName)" + } + foreach ($filter in @($case.Filters)) { + $script:AssignmentFilterLookup["$($filter.id)"] = [PSCustomObject]@{ + Name = $filter.displayName + Platform = $filter.platform + Rule = $filter.rule + } + } +} + +function ConvertTo-CanonicalParityRecord { + [CmdletBinding()] + param([Parameter(Mandatory)][object]$Record) + + function ConvertTo-NullableString { + param([AllowNull()]$Value) + if ($null -eq $Value -or [string]::IsNullOrWhiteSpace("$Value")) { return $null } + return "$Value" + } + + [PSCustomObject][ordered]@{ + categoryId = $Record.CategoryId + category = $Record.Category + policyId = $Record.PolicyId + policyName = $Record.PolicyName + platform = $Record.Platform + roleScopeTagIds = @($Record.ScopeTagIds) + assignmentId = ConvertTo-NullableString $Record.AssignmentId + assignmentMode = $Record.AssignmentMode + targetType = $Record.TargetType + targetId = ConvertTo-NullableString $Record.TargetId + targetName = ConvertTo-NullableString $Record.TargetName + intent = ConvertTo-NullableString $Record.Intent + filterId = ConvertTo-NullableString $Record.FilterId + filterName = ConvertTo-NullableString $Record.FilterName + filterMode = ConvertTo-NullableString $Record.FilterMode + filterRule = ConvertTo-NullableString $Record.FilterRule + filterPlatform = ConvertTo-NullableString $Record.FilterPlatform + source = $Record.Source + } +} + +$exportedCases = foreach ($case in $cases) { + $normalized = ConvertTo-IACNormalizedAssignment -Assignment $case.Assignment + if ($null -eq $normalized) { + throw "Parity case '$($case.Name)' did not produce a normalized PowerShell assignment." + } + $record = ConvertTo-IACAssignmentRecord ` + -Category $categories[$case.Category] ` + -Entity $case.Policy ` + -Assignment $normalized ` + -ResolveTargetName + + [PSCustomObject][ordered]@{ + name = $case.Name + category = $case.Category + policy = $case.Policy + assignment = $case.Assignment + lookups = [PSCustomObject][ordered]@{ + groups = @($case.Groups) + filters = @($case.Filters) + } + expected = ConvertTo-CanonicalParityRecord -Record $record + } +} + +$fixture = [PSCustomObject][ordered]@{ + contractName = 'IntuneAssignmentChecker.McpAssignmentParity' + contractVersion = 1 + graphApiVersion = 'beta' + generatedBy = 'IntuneAssignmentChecker PowerShell reference implementation' + cases = @($exportedCases) +} + +$json = ($fixture | ConvertTo-Json -Depth 20).Replace("`r`n", "`n") + "`n" +$resolvedOutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) +$outputDirectory = Split-Path -Parent $resolvedOutputPath +if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) { + [System.IO.Directory]::CreateDirectory($outputDirectory) | Out-Null +} +[System.IO.File]::WriteAllText( + $resolvedOutputPath, + $json, + [System.Text.UTF8Encoding]::new($false) +) +Write-Output $resolvedOutputPath diff --git a/Tests/Parity/assignment-parity.v1.json b/Tests/Parity/assignment-parity.v1.json new file mode 100644 index 0000000..d9e78d0 --- /dev/null +++ b/Tests/Parity/assignment-parity.v1.json @@ -0,0 +1,442 @@ +{ + "contractName": "IntuneAssignmentChecker.McpAssignmentParity", + "contractVersion": 1, + "graphApiVersion": "beta", + "generatedBy": "IntuneAssignmentChecker PowerShell reference implementation", + "cases": [ + { + "name": "configuration group include with assignment filter", + "category": "configurationPolicy", + "policy": { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Windows security baseline", + "displayName": null, + "platforms": "windows10", + "technologies": "mdm", + "roleScopeTagIds": [ + "0", + "7" + ] + }, + "assignment": { + "id": "assignment-group-include", + "intent": null, + "target": { + "@odata.type": "#microsoft.graph.groupAssignmentTarget", + "groupId": "22222222-2222-2222-2222-222222222222", + "deviceAndAppManagementAssignmentFilterId": "33333333-3333-3333-3333-333333333333", + "deviceAndAppManagementAssignmentFilterType": "include" + } + }, + "lookups": { + "groups": [ + { + "id": "22222222-2222-2222-2222-222222222222", + "displayName": "Pilot Devices" + } + ], + "filters": [ + { + "id": "33333333-3333-3333-3333-333333333333", + "displayName": "Corporate Windows", + "platform": "windows10AndLater", + "rule": "(device.deviceOwnership -eq \"Corporate\")" + } + ] + }, + "expected": { + "categoryId": "configurationPolicy", + "category": "Settings Catalog and Endpoint Security", + "policyId": "11111111-1111-1111-1111-111111111111", + "policyName": "Windows security baseline", + "platform": "Windows", + "roleScopeTagIds": [ + "0", + "7" + ], + "assignmentId": "assignment-group-include", + "assignmentMode": "Include", + "targetType": "Group", + "targetId": "22222222-2222-2222-2222-222222222222", + "targetName": "Pilot Devices", + "intent": null, + "filterId": "33333333-3333-3333-3333-333333333333", + "filterName": "Corporate Windows", + "filterMode": "include", + "filterRule": "(device.deviceOwnership -eq \"Corporate\")", + "filterPlatform": "windows10AndLater", + "source": "MicrosoftGraph" + } + }, + { + "name": "configuration group exclusion", + "category": "configurationPolicy", + "policy": { + "id": "44444444-4444-4444-4444-444444444444", + "name": "Windows update ring", + "displayName": null, + "platforms": "windows10", + "technologies": "mdm", + "roleScopeTagIds": [ + "0" + ] + }, + "assignment": { + "id": "assignment-group-exclude", + "intent": null, + "target": { + "@odata.type": "#microsoft.graph.exclusionGroupAssignmentTarget", + "groupId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "deviceAndAppManagementAssignmentFilterId": null, + "deviceAndAppManagementAssignmentFilterType": "none" + } + }, + "lookups": { + "groups": [ + { + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "displayName": "Excluded Devices" + } + ], + "filters": [] + }, + "expected": { + "categoryId": "configurationPolicy", + "category": "Settings Catalog and Endpoint Security", + "policyId": "44444444-4444-4444-4444-444444444444", + "policyName": "Windows update ring", + "platform": "Windows", + "roleScopeTagIds": [ + "0" + ], + "assignmentId": "assignment-group-exclude", + "assignmentMode": "Exclude", + "targetType": "Group", + "targetId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "targetName": "Excluded Devices", + "intent": null, + "filterId": null, + "filterName": null, + "filterMode": null, + "filterRule": null, + "filterPlatform": null, + "source": "MicrosoftGraph" + } + }, + { + "name": "all licensed users target", + "category": "configurationPolicy", + "policy": { + "id": "55555555-5555-5555-5555-555555555555", + "name": "User certificate policy", + "displayName": null, + "platforms": "windows10", + "technologies": "mdm", + "roleScopeTagIds": [] + }, + "assignment": { + "id": "assignment-all-users", + "intent": null, + "target": { + "@odata.type": "#microsoft.graph.allLicensedUsersAssignmentTarget", + "deviceAndAppManagementAssignmentFilterId": "33333333-3333-3333-3333-333333333333", + "deviceAndAppManagementAssignmentFilterType": "none" + } + }, + "lookups": { + "groups": [], + "filters": [ + { + "id": "33333333-3333-3333-3333-333333333333", + "displayName": "Corporate Windows", + "platform": "windows10AndLater", + "rule": "(device.deviceOwnership -eq \"Corporate\")" + } + ] + }, + "expected": { + "categoryId": "configurationPolicy", + "category": "Settings Catalog and Endpoint Security", + "policyId": "55555555-5555-5555-5555-555555555555", + "policyName": "User certificate policy", + "platform": "Windows", + "roleScopeTagIds": [], + "assignmentId": "assignment-all-users", + "assignmentMode": "Include", + "targetType": "AllUsers", + "targetId": null, + "targetName": "All Users", + "intent": null, + "filterId": null, + "filterName": null, + "filterMode": null, + "filterRule": null, + "filterPlatform": null, + "source": "MicrosoftGraph" + } + }, + { + "name": "all devices target suppresses empty filter sentinel", + "category": "configurationPolicy", + "policy": { + "id": "66666666-6666-6666-6666-666666666666", + "name": "Device restrictions", + "displayName": null, + "platforms": "windows10", + "technologies": "mdm", + "roleScopeTagIds": [ + "7" + ] + }, + "assignment": { + "id": "assignment-all-devices", + "intent": null, + "target": { + "@odata.type": "#microsoft.graph.allDevicesAssignmentTarget", + "deviceAndAppManagementAssignmentFilterId": "00000000-0000-0000-0000-000000000000", + "deviceAndAppManagementAssignmentFilterType": "include" + } + }, + "lookups": { + "groups": [], + "filters": [] + }, + "expected": { + "categoryId": "configurationPolicy", + "category": "Settings Catalog and Endpoint Security", + "policyId": "66666666-6666-6666-6666-666666666666", + "policyName": "Device restrictions", + "platform": "Windows", + "roleScopeTagIds": [ + "7" + ], + "assignmentId": "assignment-all-devices", + "assignmentMode": "Include", + "targetType": "AllDevices", + "targetId": null, + "targetName": "All Devices", + "intent": null, + "filterId": null, + "filterName": null, + "filterMode": null, + "filterRule": null, + "filterPlatform": null, + "source": "MicrosoftGraph" + } + }, + { + "name": "mobile application assignment intent and platform", + "category": "application", + "policy": { + "id": "77777777-7777-7777-7777-777777777777", + "name": null, + "displayName": "Contoso Agent", + "@odata.type": "#microsoft.graph.win32LobApp", + "roleScopeTagIds": [ + "0" + ], + "isAssigned": true + }, + "assignment": { + "id": "assignment-app-required", + "intent": "required", + "source": "direct", + "target": { + "@odata.type": "#microsoft.graph.groupAssignmentTarget", + "groupId": "22222222-2222-2222-2222-222222222222", + "deviceAndAppManagementAssignmentFilterId": null, + "deviceAndAppManagementAssignmentFilterType": "none" + } + }, + "lookups": { + "groups": [ + { + "id": "22222222-2222-2222-2222-222222222222", + "displayName": "Pilot Devices" + } + ], + "filters": [] + }, + "expected": { + "categoryId": "application", + "category": "Application", + "policyId": "77777777-7777-7777-7777-777777777777", + "policyName": "Contoso Agent", + "platform": "Windows", + "roleScopeTagIds": [ + "0" + ], + "assignmentId": "assignment-app-required", + "assignmentMode": "Include", + "targetType": "Group", + "targetId": "22222222-2222-2222-2222-222222222222", + "targetName": "Pilot Devices", + "intent": "required", + "filterId": null, + "filterName": null, + "filterMode": null, + "filterRule": null, + "filterPlatform": null, + "source": "MicrosoftGraph" + } + }, + { + "name": "device configuration with unresolved group name", + "category": "deviceConfiguration", + "policy": { + "id": "88888888-8888-8888-8888-888888888888", + "name": null, + "displayName": "Legacy device restrictions", + "@odata.type": "#microsoft.graph.windows10GeneralConfiguration", + "roleScopeTagIds": [ + "7" + ] + }, + "assignment": { + "id": "assignment-unresolved-group", + "intent": null, + "target": { + "@odata.type": "#microsoft.graph.groupAssignmentTarget", + "groupId": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "deviceAndAppManagementAssignmentFilterId": null, + "deviceAndAppManagementAssignmentFilterType": "none" + } + }, + "lookups": { + "groups": [], + "filters": [] + }, + "expected": { + "categoryId": "deviceConfiguration", + "category": "Device Configuration", + "policyId": "88888888-8888-8888-8888-888888888888", + "policyName": "Legacy device restrictions", + "platform": "Windows", + "roleScopeTagIds": [ + "7" + ], + "assignmentId": "assignment-unresolved-group", + "assignmentMode": "Include", + "targetType": "Group", + "targetId": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "targetName": null, + "intent": null, + "filterId": null, + "filterName": null, + "filterMode": null, + "filterRule": null, + "filterPlatform": null, + "source": "MicrosoftGraph" + } + }, + { + "name": "Android compliance assignment with exclude filter", + "category": "compliancePolicy", + "policy": { + "id": "99999999-9999-9999-9999-999999999999", + "name": null, + "displayName": "Corporate Android compliance", + "@odata.type": "#microsoft.graph.androidDeviceOwnerCompliancePolicy", + "roleScopeTagIds": [ + "0" + ] + }, + "assignment": { + "id": "assignment-compliance-filter-exclude", + "intent": null, + "target": { + "@odata.type": "#microsoft.graph.groupAssignmentTarget", + "groupId": "22222222-2222-2222-2222-222222222222", + "deviceAndAppManagementAssignmentFilterId": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "deviceAndAppManagementAssignmentFilterType": "exclude" + } + }, + "lookups": { + "groups": [ + { + "id": "22222222-2222-2222-2222-222222222222", + "displayName": "Pilot Devices" + } + ], + "filters": [ + { + "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "displayName": "Personally owned Android", + "platform": "androidForWork", + "rule": "(device.deviceOwnership -eq \"Personal\")" + } + ] + }, + "expected": { + "categoryId": "compliancePolicy", + "category": "Compliance Policy", + "policyId": "99999999-9999-9999-9999-999999999999", + "policyName": "Corporate Android compliance", + "platform": "Android Enterprise", + "roleScopeTagIds": [ + "0" + ], + "assignmentId": "assignment-compliance-filter-exclude", + "assignmentMode": "Include", + "targetType": "Group", + "targetId": "22222222-2222-2222-2222-222222222222", + "targetName": "Pilot Devices", + "intent": null, + "filterId": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "filterName": "Personally owned Android", + "filterMode": "exclude", + "filterRule": "(device.deviceOwnership -eq \"Personal\")", + "filterPlatform": "androidForWork", + "source": "MicrosoftGraph" + } + }, + { + "name": "Android app configuration assigned to all devices", + "category": "appConfiguration", + "policy": { + "id": "dddddddd-dddd-dddd-dddd-dddddddddddd", + "name": null, + "displayName": "Managed browser configuration", + "@odata.type": "#microsoft.graph.androidManagedStoreAppConfiguration", + "roleScopeTagIds": [], + "targetedMobileApps": [ + "77777777-7777-7777-7777-777777777777" + ] + }, + "assignment": { + "id": "assignment-app-configuration-all-devices", + "intent": null, + "source": "direct", + "target": { + "@odata.type": "#microsoft.graph.allDevicesAssignmentTarget", + "deviceAndAppManagementAssignmentFilterId": null, + "deviceAndAppManagementAssignmentFilterType": "none" + } + }, + "lookups": { + "groups": [], + "filters": [] + }, + "expected": { + "categoryId": "appConfiguration", + "category": "App Configuration Policy", + "policyId": "dddddddd-dddd-dddd-dddd-dddddddddddd", + "policyName": "Managed browser configuration", + "platform": "Android", + "roleScopeTagIds": [], + "assignmentId": "assignment-app-configuration-all-devices", + "assignmentMode": "Include", + "targetType": "AllDevices", + "targetId": null, + "targetName": "All Devices", + "intent": null, + "filterId": null, + "filterName": null, + "filterMode": null, + "filterRule": null, + "filterPlatform": null, + "source": "MicrosoftGraph" + } + } + ] +} diff --git a/Tests/README.md b/Tests/README.md index 4c621db..2fa15f4 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -28,6 +28,19 @@ network. The full suite normally completes in under a minute. **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. +The MCP parity fixture in `Tests/Parity/assignment-parity.v1.json` is generated +from the PowerShell normalization helpers by `Export-McpParityFixtures.ps1`. +Its unit test regenerates the canonical content (normalizing only CRLF to LF), +so behavior changes cannot silently leave the TypeScript reference contract +stale on any supported runner operating system. + +When the MCP GitHub repository is available, set the repository Actions variable +`IAC_MCP_REPOSITORY` to its `owner/name`. The `MCP parity gate` workflow then +checks every relevant PowerShell PR and push against the TypeScript MCP. Private +MCP repositories also require the `IAC_MCP_REPOSITORY_TOKEN` secret with read +access. This creates a release gate: update the MCP compatibly first, then merge +the PowerShell behavior change. + ### Run locally ```powershell diff --git a/Tests/Unit/McpParityFixtures.Tests.ps1 b/Tests/Unit/McpParityFixtures.Tests.ps1 new file mode 100644 index 0000000..8eb09e5 --- /dev/null +++ b/Tests/Unit/McpParityFixtures.Tests.ps1 @@ -0,0 +1,52 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } + +BeforeAll { + $exporter = Join-Path $PSScriptRoot '../Parity/Export-McpParityFixtures.ps1' + $committedFixture = Join-Path $PSScriptRoot '../Parity/assignment-parity.v1.json' + $generatedFixture = Join-Path ([System.IO.Path]::GetTempPath()) "iac-mcp-parity-$([guid]::NewGuid().ToString('N')).json" +} + +AfterAll { + if (Test-Path -LiteralPath $generatedFixture) { + Remove-Item -LiteralPath $generatedFixture -Force + } +} + +Describe 'MCP parity fixture contract' { + It 'is regenerated deterministically from the PowerShell normalization engine' { + & $exporter -OutputPath $generatedFixture | Out-Null + + Test-Path -LiteralPath $committedFixture | Should -BeTrue + $generatedContent = [System.IO.File]::ReadAllText($generatedFixture).Replace("`r`n", "`n") + $committedContent = [System.IO.File]::ReadAllText($committedFixture).Replace("`r`n", "`n") + $generatedContent | Should -BeExactly $committedContent + } + + It 'uses the versioned beta contract and covers every supported category and assignment target mode' { + $fixture = Get-Content -LiteralPath $committedFixture -Raw | ConvertFrom-Json -Depth 20 + + $fixture.contractName | Should -BeExactly 'IntuneAssignmentChecker.McpAssignmentParity' + $fixture.contractVersion | Should -Be 1 + $fixture.graphApiVersion | Should -BeExactly 'beta' + @($fixture.cases).Count | Should -BeGreaterOrEqual 8 + $assignmentModes = @($fixture.cases.expected.assignmentMode | Sort-Object -Unique) + foreach ($expectedMode in @('Exclude', 'Include')) { + $assignmentModes | Should -Contain $expectedMode + } + $targetTypes = @($fixture.cases.expected.targetType | Sort-Object -Unique) + foreach ($expectedTargetType in @('AllDevices', 'AllUsers', 'Group')) { + $targetTypes | Should -Contain $expectedTargetType + } + $categoryIds = @($fixture.cases.category | Sort-Object -Unique) + foreach ($expectedCategory in @( + 'configurationPolicy' + 'deviceConfiguration' + 'compliancePolicy' + 'application' + 'appConfiguration' + )) { + $categoryIds | Should -Contain $expectedCategory + } + } +} From fcc2849ccbdb71ed05f623b90b6827b202d1cbdc Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:22:30 +0200 Subject: [PATCH 12/13] fix: resolve side-by-side module installations --- .github/workflows/windows-package.yml | 8 + .../Start-IntuneAssignmentCheckerTui.ps1 | 18 +- ...est-IntuneAssignmentCheckerEnvironment.ps1 | 138 ++++++++++++++ Tests/Release/ModulePackage.Tests.ps1 | 106 ++++++++++- Tests/Unit/V5Platform.Tests.ps1 | 81 +++++++++ packaging/Build-WindowsInstaller.ps1 | 3 + packaging/IntuneAssignmentChecker.cmd | 15 +- packaging/IntuneAssignmentChecker.wxs | 4 + packaging/README.md | 26 ++- packaging/Start-IntuneAssignmentChecker.ps1 | 170 ++++++++++++++++++ 10 files changed, 555 insertions(+), 14 deletions(-) create mode 100644 packaging/Start-IntuneAssignmentChecker.ps1 diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 4ba6b2b..cc961bb 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -88,6 +88,7 @@ jobs: $commandFolder = Join-Path $env:ProgramFiles 'Intune Assignment Checker\bin' $normalizedCommandFolder = $commandFolder.TrimEnd([IO.Path]::DirectorySeparatorChar) $launcherPath = Join-Path $commandFolder 'IntuneAssignmentChecker.cmd' + $bootstrapPath = Join-Path $commandFolder 'Start-IntuneAssignmentChecker.ps1' $installed = $false try { Write-Host "Installing $msi" @@ -109,6 +110,9 @@ jobs: if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) { throw "The PowerShell 7 command launcher was not installed at '$launcherPath'." } + if (-not (Test-Path -LiteralPath $bootstrapPath -PathType Leaf)) { + throw "The deterministic PowerShell launcher was not installed at '$bootstrapPath'." + } $machinePath = [Environment]::GetEnvironmentVariable('PATH', 'Machine') $machinePathEntries = @($machinePath -split ';' | ForEach-Object { $_.Trim().TrimEnd([IO.Path]::DirectorySeparatorChar) }) if ($machinePathEntries -notcontains $normalizedCommandFolder) { @@ -122,6 +126,10 @@ jobs: if (($launcherOutput -join [Environment]::NewLine) -notmatch 'is ready in PowerShell 7') { throw "The installed command launcher did not confirm a PowerShell 7 handoff: $($launcherOutput -join ' ')" } + $expectedModuleRoot = Join-Path $env:ProgramFiles 'PowerShell\Modules\IntuneAssignmentChecker' + if (($launcherOutput -join [Environment]::NewLine) -notmatch [regex]::Escape($expectedModuleRoot)) { + throw "The command launcher did not report the MSI module source '$expectedModuleRoot': $($launcherOutput -join ' ')" + } @' param([Parameter(Mandatory)][string]$Version) diff --git a/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 b/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 index cf80f5d..69200d3 100644 --- a/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Start-IntuneAssignmentCheckerTui.ps1 @@ -44,13 +44,27 @@ function Start-IntuneAssignmentCheckerTui { } $state = New-IACTuiState -InitialView $InitialView + $launchNotice = [Environment]::GetEnvironmentVariable('IAC_LAUNCH_NOTICE', 'Process') + if (-not [string]::IsNullOrWhiteSpace($launchNotice)) { + [Environment]::SetEnvironmentVariable('IAC_LAUNCH_NOTICE', $null, 'Process') + } try { $terminal = Enable-IACTuiTerminal -State $state -DisableMouse:$DisableMouse + $statusMessages = @() + $statusStyle = 'Muted' if ($DisableMouse) { - Set-IACTuiStatus -State $state -Message 'Mouse input disabled; all features remain available from the keyboard.' -Style Muted + $statusMessages += 'Mouse input disabled; all features remain available from the keyboard.' } elseif (-not $terminal.MouseEnabled) { - Set-IACTuiStatus -State $state -Message 'Mouse reporting is unavailable in this terminal; keyboard navigation is fully supported.' -Style Warning + $statusMessages += 'Mouse reporting is unavailable in this terminal; keyboard navigation is fully supported.' + $statusStyle = 'Warning' + } + if (-not [string]::IsNullOrWhiteSpace($launchNotice)) { + $statusMessages += $launchNotice + $statusStyle = 'Warning' + } + if ($statusMessages.Count -gt 0) { + Set-IACTuiStatus -State $state -Message ($statusMessages -join ' ') -Style $statusStyle } while (-not $state.ExitRequested) { diff --git a/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentCheckerEnvironment.ps1 b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentCheckerEnvironment.ps1 index b1adcb3..ab3ba22 100644 --- a/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentCheckerEnvironment.ps1 +++ b/Module/IntuneAssignmentChecker/Public/Test-IntuneAssignmentCheckerEnvironment.ps1 @@ -48,6 +48,144 @@ function Test-IntuneAssignmentCheckerEnvironment { } & $addResult 'PowerShellVersion' $(if ($PSVersionTable.PSVersion.Major -ge 7) { 'Passed' } else { 'Failed' }) "$($PSVersionTable.PSVersion)" 'Install PowerShell 7 or newer.' 'Core' + + $loadedModule = $MyInvocation.MyCommand.Module + if (-not $loadedModule -or [string]::IsNullOrWhiteSpace($loadedModule.Path)) { + $loadedModule = Get-Module -Name IntuneAssignmentChecker -ErrorAction SilentlyContinue | + Where-Object { -not [string]::IsNullOrWhiteSpace($_.Path) } | + Sort-Object Version -Descending | + Select-Object -First 1 + } + $availableModules = @( + Get-Module -ListAvailable -Name IntuneAssignmentChecker -ErrorAction SilentlyContinue | + Where-Object { -not [string]::IsNullOrWhiteSpace($_.Path) } + ) + $scopePathComparer = [StringComparer]::OrdinalIgnoreCase + $getModuleBase = { + param($Module) + $moduleBase = if ($Module -and -not [string]::IsNullOrWhiteSpace($Module.ModuleBase)) { + $Module.ModuleBase + } + elseif ($Module -and -not [string]::IsNullOrWhiteSpace($Module.Path)) { + [IO.Path]::GetDirectoryName($Module.Path) + } + else { + $null + } + if ($moduleBase) { + [IO.Path]::GetFullPath($moduleBase).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + } + } + $loadedBase = & $getModuleBase $loadedModule + $availableLoadedBase = @( + $availableModules | Where-Object { + $availableBase = & $getModuleBase $_ + $loadedBase -and $availableBase -and $scopePathComparer.Equals($availableBase, $loadedBase) + } + ) + if ($loadedModule -and $availableLoadedBase.Count -eq 0) { + $availableModules = @($loadedModule) + $availableModules + } + + $moduleSearchRootPaths = [Collections.Generic.HashSet[string]]::new($scopePathComparer) + $moduleSearchRoots = @( + foreach ($searchRoot in @($env:PSModulePath -split [IO.Path]::PathSeparator)) { + if (-not [string]::IsNullOrWhiteSpace($searchRoot)) { + $normalizedRoot = [IO.Path]::GetFullPath($searchRoot).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + if ($moduleSearchRootPaths.Add($normalizedRoot)) { + $normalizedRoot + } + } + } + ) + $moduleSearchRoots = @($moduleSearchRoots | Sort-Object Length -Descending) + $moduleIdentities = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $moduleInstallations = @( + $availableModules | + ForEach-Object { + $prerelease = "$($_.PrivateData.PSData.Prerelease)" + $displayVersion = "$($_.Version)" + if (-not [string]::IsNullOrWhiteSpace($prerelease)) { + $displayVersion += "-$prerelease" + } + $moduleBase = & $getModuleBase $_ + [PSCustomObject]@{ + Version = [version]$_.Version + IsStable = [string]::IsNullOrWhiteSpace($prerelease) + Prerelease = $prerelease + DisplayVersion = $displayVersion + Path = [IO.Path]::GetFullPath($_.Path) + ModuleBase = $moduleBase + Identity = "$moduleBase|$displayVersion" + } + } | + Sort-Object -Property @( + @{ Expression = { $_.Version }; Descending = $true } + @{ Expression = { $_.IsStable }; Descending = $true } + @{ Expression = { $_.Prerelease }; Descending = $true } + @{ Expression = { $_.Path }; Ascending = $true } + ) | + Where-Object { $moduleIdentities.Add($_.Identity) } + ) + $moduleScopes = [Collections.Generic.HashSet[string]]::new($scopePathComparer) + foreach ($installation in $moduleInstallations) { + $installationPath = [IO.Path]::GetFullPath($installation.Path) + $scopeRoot = $null + foreach ($searchRoot in $moduleSearchRoots) { + $searchPrefix = $searchRoot + [IO.Path]::DirectorySeparatorChar + if ($installationPath.StartsWith($searchPrefix, [StringComparison]::OrdinalIgnoreCase)) { + $scopeRoot = $searchRoot + break + } + } + if (-not $scopeRoot) { + $manifestDirectory = [IO.Path]::GetDirectoryName($installationPath) + $scopeRoot = [IO.Path]::GetDirectoryName($manifestDirectory) + $versionDirectoryName = [IO.Path]::GetFileName($manifestDirectory) + $isVersionDirectory = $false + try { + [void][version]$versionDirectoryName + $isVersionDirectory = $true + } + catch { + try { + [void][System.Management.Automation.SemanticVersion]$versionDirectoryName + $isVersionDirectory = $true + } + catch { + # A non-versioned module lives directly beneath its scope root. + } + } + if ($isVersionDirectory) { + $scopeRoot = [IO.Path]::GetDirectoryName($scopeRoot) + } + } + [void]$moduleScopes.Add($scopeRoot) + } + $installationDetails = @( + foreach ($installation in $moduleInstallations) { + $installationPath = [IO.Path]::GetFullPath($installation.Path) + $isLoaded = $loadedBase -and $installation.ModuleBase -and $scopePathComparer.Equals($installation.ModuleBase, $loadedBase) + $loadedLabel = if ($isLoaded) { ' [loaded]' } else { '' } + "v$($installation.DisplayVersion) at $installationPath$loadedLabel" + } + ) + $installationStatus = if ($moduleScopes.Count -gt 1) { 'Warning' } elseif ($moduleInstallations.Count -ge 1) { 'Passed' } else { 'Failed' } + $installationDetail = if ($installationDetails.Count -gt 0) { $installationDetails -join '; ' } else { 'No installation was discovered.' } + $installationRemediation = if ($moduleScopes.Count -gt 1) { + 'Keep one installation scope, or remove the extra copy with the package manager that installed it (Uninstall-Module or winget uninstall).' + } + else { + 'Install from PowerShell Gallery or WinGet if the module is unavailable.' + } + & $addResult 'ModuleInstallations' $installationStatus $installationDetail $installationRemediation 'Core' + $graphModule = Get-Module -ListAvailable -Name Microsoft.Graph.Authentication | Sort-Object Version -Descending | Select-Object -First 1 & $addResult 'GraphAuthenticationModule' $(if ($graphModule) { 'Passed' } else { 'Failed' }) $(if ($graphModule) { "$($graphModule.Version)" } else { 'Not installed' }) 'Install-Module Microsoft.Graph.Authentication -Scope CurrentUser' 'Core' diff --git a/Tests/Release/ModulePackage.Tests.ps1 b/Tests/Release/ModulePackage.Tests.ps1 index 489834d..26de519 100644 --- a/Tests/Release/ModulePackage.Tests.ps1 +++ b/Tests/Release/ModulePackage.Tests.ps1 @@ -77,24 +77,128 @@ Describe 'IntuneAssignmentChecker release package' { It 'ships a PowerShell 7 command handoff without duplicating application logic' { $launcherPath = Join-Path $repoRoot 'packaging/IntuneAssignmentChecker.cmd' + $bootstrapPath = Join-Path $repoRoot 'packaging/Start-IntuneAssignmentChecker.ps1' Test-Path -LiteralPath $launcherPath -PathType Leaf | Should -BeTrue + Test-Path -LiteralPath $bootstrapPath -PathType Leaf | Should -BeTrue $launcher = Get-Content -LiteralPath $launcherPath -Raw + $bootstrap = Get-Content -LiteralPath $bootstrapPath -Raw $launcher | Should -Match 'pwsh\.exe' - $launcher | Should -Match 'Start-IntuneAssignmentCheckerTui' + $launcher | Should -Match 'Start-IntuneAssignmentChecker\.ps1' $launcher | Should -Match 'requires PowerShell 7' $launcher | Should -Not -Match '(?i)powershell\.exe' + @([regex]::Matches($launcher, '-ExecutionPolicy Bypass')).Count | Should -Be 3 + $launcher | Should -Match ':bootstrap_not_found' + $launcher | Should -Match 'winget install --id UgurKoc\.IntuneAssignmentChecker --exact --force' + $bootstrap | Should -Match 'Start-IntuneAssignmentCheckerTui' + $bootstrap | Should -Match 'Get-Module -ListAvailable' + $bootstrap | Should -Match 'IsMachineInstallation' + $bootstrap | Should -Match 'Import-Module -Name \$selected\.Path' + $bootstrap | Should -Match 'IAC_LAUNCH_NOTICE' + $tuiLauncher = Get-Content -LiteralPath (Join-Path $moduleRoot 'Public/Start-IntuneAssignmentCheckerTui.ps1') -Raw + $tuiLauncher | Should -Match 'IAC_LAUNCH_NOTICE' $wix = Get-Content (Join-Path $repoRoot 'packaging/IntuneAssignmentChecker.wxs') -Raw $wix | Should -Match 'LauncherSource' + $wix | Should -Match 'Start-IntuneAssignmentChecker\.ps1' + $wix | Should -Match 'Component Id="PowerShellLauncher"' $wix | Should -Match 'Name="PATH"' $wix | Should -Match 'System="yes"' $buildScript = Get-Content (Join-Path $repoRoot 'packaging/Build-WindowsInstaller.ps1') -Raw $buildScript | Should -Match 'launcherStagingRoot' + $buildScript | Should -Match 'bootstrapDestination' $buildScript | Should -Match 'Replace\("`r`n", "`n"\)' $buildScript | Should -Match 'UTF8Encoding.*false' } + It 'selects the newest module and uses the MSI scope to break version ties' { + $bootstrapPath = Join-Path $repoRoot 'packaging/Start-IntuneAssignmentChecker.ps1' + $programFiles = Join-Path $TestDrive 'Program Files' + $machineModules = Join-Path (Join-Path $programFiles 'PowerShell') 'Modules' + $userModules = Join-Path $TestDrive 'UserModules' + + function Add-TestModule { + param([string]$Root, [string]$Version, [string]$Prerelease) + $versionRoot = Join-Path (Join-Path $Root 'IntuneAssignmentChecker') $Version + New-Item -ItemType Directory -Path $versionRoot -Force | Out-Null + [IO.File]::WriteAllText( + (Join-Path $versionRoot 'IntuneAssignmentChecker.psm1'), + 'function Start-IntuneAssignmentCheckerTui { param([switch]$DisableMouse) }', + [Text.UTF8Encoding]::new($false) + ) + $manifestParameters = @{ + Path = Join-Path $versionRoot 'IntuneAssignmentChecker.psd1' + RootModule = 'IntuneAssignmentChecker.psm1' + ModuleVersion = $Version + FunctionsToExport = 'Start-IntuneAssignmentCheckerTui' + } + if ($Prerelease) { $manifestParameters.Prerelease = $Prerelease } + New-ModuleManifest @manifestParameters + return [IO.Path]::GetFullPath((Join-Path $versionRoot 'IntuneAssignmentChecker.psd1')) + } + + $machineManifest = Add-TestModule -Root $machineModules -Version '5.0.0' + $null = Add-TestModule -Root $userModules -Version '5.0.0' + $pathSeparator = [IO.Path]::PathSeparator + $savedModulePath = $env:PSModulePath + $savedProgramFiles = $env:ProgramFiles + $savedProgramW6432 = $env:ProgramW6432 + try { + $env:PSModulePath = "$userModules$pathSeparator$machineModules" + $env:ProgramFiles = Join-Path $TestDrive 'Program Files (x86)' + $env:ProgramW6432 = $programFiles + $tieOutput = @(& pwsh -NoLogo -NoProfile -File $bootstrapPath -Check 2>&1) + $tieExitCode = $LASTEXITCODE + + $newestManifest = Add-TestModule -Root $userModules -Version '5.1.0' + $newestOutput = @(& pwsh -NoLogo -NoProfile -File $bootstrapPath -Check 2>&1) + $newestExitCode = $LASTEXITCODE + + $null = Add-TestModule -Root $machineModules -Version '5.1.0' -Prerelease 'preview1' + $stableOutput = @(& pwsh -NoLogo -NoProfile -File $bootstrapPath -Check 2>&1) + $stableExitCode = $LASTEXITCODE + + $fourPartManifest = Add-TestModule -Root $userModules -Version '5.2.0.1' + $fourPartOutput = @(& pwsh -NoLogo -NoProfile -File $bootstrapPath -Check 2>&1) + $fourPartExitCode = $LASTEXITCODE + + $env:PSModulePath = $userModules + $singleScopeOutput = @(& pwsh -NoLogo -NoProfile -File $bootstrapPath -Check 2>&1) + $singleScopeExitCode = $LASTEXITCODE + + $emptyModules = Join-Path $TestDrive 'EmptyModules' + New-Item -ItemType Directory -Path $emptyModules -Force | Out-Null + $env:PSModulePath = $emptyModules + $notInstalledOutput = @(& pwsh -NoLogo -NoProfile -File $bootstrapPath -Check 2>&1) + $notInstalledExitCode = $LASTEXITCODE + } + finally { + $env:PSModulePath = $savedModulePath + $env:ProgramFiles = $savedProgramFiles + $env:ProgramW6432 = $savedProgramW6432 + } + + $tieExitCode | Should -Be 0 + ($tieOutput -join "`n") | Should -Match ([regex]::Escape($machineManifest)) + ($tieOutput -join "`n") | Should -Match 'multiple module scopes' + $newestExitCode | Should -Be 0 + ($newestOutput -join "`n") | Should -Match ([regex]::Escape($newestManifest)) + $stableExitCode | Should -Be 0 + ($stableOutput -join "`n") | Should -Match ([regex]::Escape($newestManifest)) + ($stableOutput -join "`n") | Should -Not -Match '5\.1\.0-preview1' + + $singleScopeExitCode | Should -Be 0 + ($singleScopeOutput -join "`n") | Should -Not -Match 'multiple module scopes' + ($singleScopeOutput -join "`n") | Should -Match ([regex]::Escape($fourPartManifest)) + $fourPartExitCode | Should -Be 0 + ($fourPartOutput -join "`n") | Should -Match ([regex]::Escape($fourPartManifest)) + ($fourPartOutput -join "`n") | Should -Match '5\.2\.0\.1' + $notInstalledExitCode | Should -Not -Be 0 + ($notInstalledOutput -join "`n") | Should -Match 'UgurKoc\.IntuneAssignmentChecker' + ($notInstalledOutput -join "`n") | Should -Match 'Install-Module' + ($notInstalledOutput -join "`n") | Should -Match 'IntuneAssignmentChecker -Scope CurrentUser' + } + It 'publishes the launch command and PowerShell 5.1 guidance in WinGet metadata' { $fakeInstaller = Join-Path $TestDrive 'IntuneAssignmentChecker-5.0.0-x64.msi' Set-Content -LiteralPath $fakeInstaller -Value 'test installer' -NoNewline diff --git a/Tests/Unit/V5Platform.Tests.ps1 b/Tests/Unit/V5Platform.Tests.ps1 index a36e539..67323c9 100644 --- a/Tests/Unit/V5Platform.Tests.ps1 +++ b/Tests/Unit/V5Platform.Tests.ps1 @@ -791,6 +791,87 @@ Describe 'v5 coverage-aware commands' { } Describe 'v5 environment diagnostics' { + It 'reports every module installation and identifies the loaded copy' { + $loadedModule = Get-Module IntuneAssignmentChecker + $galleryPath = [IO.Path]::GetFullPath((Join-Path (Join-Path (Join-Path $TestDrive 'CurrentUser') 'IntuneAssignmentChecker/5.0.0') 'IntuneAssignmentChecker.psd1')) + $machinePath = [IO.Path]::GetFullPath((Join-Path (Join-Path (Join-Path $TestDrive 'AllUsers') 'IntuneAssignmentChecker/5.0.0') 'IntuneAssignmentChecker.psd1')) + $loadedManifestPath = Join-Path $loadedModule.ModuleBase 'IntuneAssignmentChecker.psd1' + Mock Get-Module -ModuleName IntuneAssignmentChecker -ParameterFilter { + $ListAvailable -and $Name -eq 'IntuneAssignmentChecker' + } { + @( + [PSCustomObject]@{ Version = [version]'5.0.0'; Path = $galleryPath } + [PSCustomObject]@{ Version = [version]'5.0.0'; Path = $machinePath } + [PSCustomObject]@{ Version = [version]'5.0.0'; Path = $galleryPath } + [PSCustomObject]@{ + Version = $loadedModule.Version + Path = $loadedManifestPath + ModuleBase = $loadedModule.ModuleBase + } + ) + } + Mock Get-Module -ModuleName IntuneAssignmentChecker -ParameterFilter { + $ListAvailable -and $Name -eq 'Microsoft.Graph.Authentication' + } { + [PSCustomObject]@{ Version = [version]'2.38.1' } + } + + $diagnostic = Test-IntuneAssignmentCheckerEnvironment -SkipGraphProbe | + Where-Object Check -EQ ModuleInstallations + + $diagnostic.Status | Should -BeExactly Warning + $diagnostic.Detail | Should -Match ([regex]::Escape($galleryPath)) + $diagnostic.Detail | Should -Match ([regex]::Escape($machinePath)) + $diagnostic.Detail | Should -Match ([regex]::Escape($loadedManifestPath)) + $diagnostic.Detail | Should -Not -Match ([regex]::Escape($loadedModule.Path)) + $diagnostic.Detail | Should -Match '\[loaded\]' + @($diagnostic.Detail -split '; ').Count | Should -Be 3 + @([regex]::Matches($diagnostic.Detail, [regex]::Escape($galleryPath))).Count | Should -Be 1 + } + + It 'treats side-by-side versions in one module scope as healthy' { + $loadedModule = Get-Module IntuneAssignmentChecker + $moduleScope = Split-Path (Split-Path $loadedModule.Path -Parent) -Parent + $olderPath = [IO.Path]::GetFullPath( + (Join-Path (Join-Path (Join-Path $moduleScope 'IntuneAssignmentChecker') '4.9.0') 'IntuneAssignmentChecker.psd1') + ) + $fourPartPath = [IO.Path]::GetFullPath( + (Join-Path (Join-Path (Join-Path $moduleScope 'IntuneAssignmentChecker') '4.8.0.1') 'IntuneAssignmentChecker.psd1') + ) + Mock Get-Module -ModuleName IntuneAssignmentChecker -ParameterFilter { + $ListAvailable -and $Name -eq 'IntuneAssignmentChecker' + } { + @( + $loadedModule + [PSCustomObject]@{ + Version = [version]'4.9.0' + Path = $olderPath + PrivateData = @{ PSData = @{ Prerelease = 'preview1' } } + } + [PSCustomObject]@{ Version = [version]'4.8.0.1'; Path = $fourPartPath } + ) + } + Mock Get-Module -ModuleName IntuneAssignmentChecker -ParameterFilter { + $ListAvailable -and $Name -eq 'Microsoft.Graph.Authentication' + } { + [PSCustomObject]@{ Version = [version]'2.38.1' } + } + $savedModulePath = $env:PSModulePath + try { + $env:PSModulePath = $moduleScope + $diagnostic = Test-IntuneAssignmentCheckerEnvironment -SkipGraphProbe | + Where-Object Check -EQ ModuleInstallations + } + finally { + $env:PSModulePath = $savedModulePath + } + + $diagnostic.Status | Should -BeExactly Passed + @($diagnostic.Detail -split '; ').Count | Should -Be 3 + $diagnostic.Detail | Should -Match 'v4\.9\.0-preview1' + $diagnostic.Detail | Should -Match 'v4\.8\.0\.1' + } + It 'uses first-page-only beta probes for every applicable workload' { & (Get-Module IntuneAssignmentChecker) { $script:GraphEndpoint = 'https://graph.microsoft.com' diff --git a/packaging/Build-WindowsInstaller.ps1 b/packaging/Build-WindowsInstaller.ps1 index 73d738e..7433f6c 100644 --- a/packaging/Build-WindowsInstaller.ps1 +++ b/packaging/Build-WindowsInstaller.ps1 @@ -72,6 +72,9 @@ $launcherDestination = Join-Path $launcherStagingRoot 'IntuneAssignmentChecker.c $launcherText = [IO.File]::ReadAllText($launcherSource) $launcherText = $launcherText.Replace("`r`n", "`n").Replace("`r", "`n").Replace("`n", "`r`n") [IO.File]::WriteAllText($launcherDestination, $launcherText, [Text.UTF8Encoding]::new($false)) +$bootstrapSource = Join-Path $PSScriptRoot 'Start-IntuneAssignmentChecker.ps1' +$bootstrapDestination = Join-Path $launcherStagingRoot 'Start-IntuneAssignmentChecker.ps1' +Copy-Item -LiteralPath $bootstrapSource -Destination $bootstrapDestination -Force $outputPath = Join-Path $resolvedOutput "IntuneAssignmentChecker-$Version-x64.msi" $wixOutput = @(& wix build (Join-Path $PSScriptRoot 'IntuneAssignmentChecker.wxs') -arch x64 ` diff --git a/packaging/IntuneAssignmentChecker.cmd b/packaging/IntuneAssignmentChecker.cmd index 139d91b..6e6918e 100644 --- a/packaging/IntuneAssignmentChecker.cmd +++ b/packaging/IntuneAssignmentChecker.cmd @@ -6,6 +6,8 @@ if defined ProgramW6432 if exist "%ProgramW6432%\PowerShell\7\pwsh.exe" set "IAC if not defined IAC_PWSH if exist "%ProgramFiles%\PowerShell\7\pwsh.exe" set "IAC_PWSH=%ProgramFiles%\PowerShell\7\pwsh.exe" if not defined IAC_PWSH for /f "delims=" %%P in ('where pwsh.exe 2^>nul') do if not defined IAC_PWSH set "IAC_PWSH=%%P" if not defined IAC_PWSH goto powershell_not_found +set "IAC_BOOTSTRAP=%~dp0Start-IntuneAssignmentChecker.ps1" +if not exist "%IAC_BOOTSTRAP%" goto bootstrap_not_found if "%~1"=="" goto launch if not "%~2"=="" goto usage @@ -17,18 +19,18 @@ goto usage echo Intune Assignment Checker requires PowerShell 7. echo Starting it now... echo. -"%IAC_PWSH%" -NoLogo -NoProfile -Command "Import-Module IntuneAssignmentChecker -ErrorAction Stop; Start-IntuneAssignmentCheckerTui" +"%IAC_PWSH%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%IAC_BOOTSTRAP%" goto finish :launch_without_mouse echo Intune Assignment Checker requires PowerShell 7. echo Starting it now with terminal mouse reporting disabled... echo. -"%IAC_PWSH%" -NoLogo -NoProfile -Command "Import-Module IntuneAssignmentChecker -ErrorAction Stop; Start-IntuneAssignmentCheckerTui -DisableMouse" +"%IAC_PWSH%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%IAC_BOOTSTRAP%" -DisableMouse goto finish :check -"%IAC_PWSH%" -NoLogo -NoProfile -Command "$required = [version]'7.0'; if ($PSVersionTable.PSVersion -lt $required) { Write-Error 'Intune Assignment Checker requires PowerShell 7 or newer.'; exit 1 }; Import-Module IntuneAssignmentChecker -ErrorAction Stop; $module = Get-Module IntuneAssignmentChecker; Write-Output ('Intune Assignment Checker {0} is ready in PowerShell {1}.' -f $module.Version, $PSVersionTable.PSVersion)" +"%IAC_PWSH%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%IAC_BOOTSTRAP%" -Check goto finish :powershell_not_found @@ -37,6 +39,13 @@ echo Install it with: echo winget install --id Microsoft.PowerShell --exact exit /b 1 +:bootstrap_not_found +echo Intune Assignment Checker's PowerShell launcher was not found: +echo %IAC_BOOTSTRAP% +echo Reinstall it with: +echo winget install --id UgurKoc.IntuneAssignmentChecker --exact --force +exit /b 1 + :usage echo Usage: IntuneAssignmentChecker [--disable-mouse ^| --check] exit /b 2 diff --git a/packaging/IntuneAssignmentChecker.wxs b/packaging/IntuneAssignmentChecker.wxs index 38e059e..3802927 100644 --- a/packaging/IntuneAssignmentChecker.wxs +++ b/packaging/IntuneAssignmentChecker.wxs @@ -24,6 +24,7 @@ + @@ -44,5 +45,8 @@ Part="last" System="yes" /> + + + diff --git a/packaging/README.md b/packaging/README.md index f5cba73..040288f 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -5,14 +5,24 @@ the exact module source plus the pinned `Microsoft.Graph.Authentication` runtime dependency into `C:\Program Files\PowerShell\Modules`. It does not compile or wrap the module as an executable. -The MSI also installs a small `IntuneAssignmentChecker.cmd` handoff in -`C:\Program Files\Intune Assignment Checker\bin` and adds that directory to the -system `PATH`. The handoff contains no application logic: it starts PowerShell 7 -and invokes `Start-IntuneAssignmentCheckerTui` from the installed module. This lets -someone type `IntuneAssignmentChecker` from Windows PowerShell 5.1, Command Prompt, -or a fresh PowerShell 7 session without receiving the module-manifest compatibility -error. A new terminal is required after the first installation so it inherits the -updated `PATH`. +The MSI also installs a small `IntuneAssignmentChecker.cmd` handoff and its +PowerShell 7 bootstrap in `C:\Program Files\Intune Assignment Checker\bin`, then +adds that directory to the system `PATH`. The handoff contains no application +logic: it starts PowerShell 7 and invokes `Start-IntuneAssignmentCheckerTui` from +the installed module. This lets someone type `IntuneAssignmentChecker` from +Windows PowerShell 5.1, Command Prompt, or a fresh PowerShell 7 session without +receiving the module-manifest compatibility error. A new terminal is required +after the first installation so it inherits the updated `PATH`. + +The bootstrap supports side-by-side PowerShell Gallery and WinGet installations. +It imports the highest available module version by its exact manifest path. When +the highest version exists in more than one scope, the machine-wide WinGet/MSI +copy wins the tie. A one-line warning identifies copies found in multiple module +scopes and the selected path in the TUI status bar (and in `--check` output); +`Test-IntuneAssignmentCheckerEnvironment` reports the full list. Multiple versions +retained within one scope are treated as a normal PowerShell module update history +and do not produce a warning. Stable releases take precedence over prereleases +with the same base version. Build on Windows with PowerShell 7, the .NET SDK, and WiX 6.0.2: diff --git a/packaging/Start-IntuneAssignmentChecker.ps1 b/packaging/Start-IntuneAssignmentChecker.ps1 new file mode 100644 index 0000000..66bdad0 --- /dev/null +++ b/packaging/Start-IntuneAssignmentChecker.ps1 @@ -0,0 +1,170 @@ +#Requires -Version 7.0 + +[CmdletBinding(DefaultParameterSetName = 'Launch')] +param( + [Parameter(ParameterSetName = 'Launch')] + [switch]$DisableMouse, + + [Parameter(Mandatory, ParameterSetName = 'Check')] + [switch]$Check +) + +$ErrorActionPreference = 'Stop' +$moduleName = 'IntuneAssignmentChecker' + +$scopePathComparer = [StringComparer]::OrdinalIgnoreCase +$seenScopeRoots = [Collections.Generic.HashSet[string]]::new($scopePathComparer) +$moduleSearchRoots = @( + foreach ($searchRoot in @($env:PSModulePath -split [IO.Path]::PathSeparator)) { + if (-not [string]::IsNullOrWhiteSpace($searchRoot)) { + $normalizedRoot = [IO.Path]::GetFullPath($searchRoot).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + if ($seenScopeRoots.Add($normalizedRoot)) { + $normalizedRoot + } + } + } +) +$moduleSearchRoots = @($moduleSearchRoots | Sort-Object Length -Descending) + +$machineModuleRoot = $null +$machineProgramFiles = if (-not [string]::IsNullOrWhiteSpace($env:ProgramW6432)) { + $env:ProgramW6432 +} +else { + $env:ProgramFiles +} +if (-not [string]::IsNullOrWhiteSpace($machineProgramFiles)) { + $machineModuleRoot = [IO.Path]::GetFullPath( + (Join-Path $machineProgramFiles 'PowerShell/Modules/IntuneAssignmentChecker') + ).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) +} + +$pathComparer = [StringComparer]::OrdinalIgnoreCase +$seenPaths = [Collections.Generic.HashSet[string]]::new($pathComparer) +$candidates = @( + Get-Module -ListAvailable -Name $moduleName -ErrorAction SilentlyContinue | + Where-Object { -not [string]::IsNullOrWhiteSpace($_.Path) } | + ForEach-Object { + $candidatePath = [IO.Path]::GetFullPath($_.Path) + if ($seenPaths.Add($candidatePath)) { + $isMachineInstallation = $false + if ($machineModuleRoot) { + $machinePrefix = $machineModuleRoot + [IO.Path]::DirectorySeparatorChar + $isMachineInstallation = $candidatePath.StartsWith( + $machinePrefix, + [StringComparison]::OrdinalIgnoreCase + ) + } + + $scopeRoot = $null + foreach ($searchRoot in $moduleSearchRoots) { + $searchPrefix = $searchRoot + [IO.Path]::DirectorySeparatorChar + if ($candidatePath.StartsWith($searchPrefix, [StringComparison]::OrdinalIgnoreCase)) { + $scopeRoot = $searchRoot + break + } + } + if (-not $scopeRoot) { + $manifestDirectory = [IO.Path]::GetDirectoryName($candidatePath) + $scopeRoot = [IO.Path]::GetDirectoryName($manifestDirectory) + $versionDirectoryName = [IO.Path]::GetFileName($manifestDirectory) + $isVersionDirectory = $false + try { + [void][version]$versionDirectoryName + $isVersionDirectory = $true + } + catch { + try { + [void][System.Management.Automation.SemanticVersion]$versionDirectoryName + $isVersionDirectory = $true + } + catch { + # A non-versioned module lives directly beneath its scope root. + } + } + if ($isVersionDirectory) { + $scopeRoot = [IO.Path]::GetDirectoryName($scopeRoot) + } + } + + $prerelease = "$($_.PrivateData.PSData.Prerelease)" + $displayVersion = "$($_.Version)" + if (-not [string]::IsNullOrWhiteSpace($prerelease)) { + $displayVersion += "-$prerelease" + } + + [PSCustomObject]@{ + Path = $candidatePath + Version = [version]$_.Version + IsStable = [string]::IsNullOrWhiteSpace($prerelease) + Prerelease = $prerelease + DisplayVersion = $displayVersion + IsMachineInstallation = $isMachineInstallation + ScopeRoot = $scopeRoot + } + } + } +) + +if ($candidates.Count -eq 0) { + [Console]::Error.WriteLine('IntuneAssignmentChecker is not installed.') + [Console]::Error.WriteLine("Install it with 'winget install --id UgurKoc.IntuneAssignmentChecker --exact' or 'Install-Module IntuneAssignmentChecker -Scope CurrentUser'.") + exit 1 +} + +$selected = @( + $candidates | Sort-Object -Property @( + @{ Expression = { $_.Version }; Descending = $true } + @{ Expression = { $_.IsStable }; Descending = $true } + @{ Expression = { $_.Prerelease }; Descending = $true } + @{ Expression = { $_.IsMachineInstallation }; Descending = $true } + @{ Expression = { $_.Path }; Ascending = $true } + ) +)[0] + +$installationScopes = [Collections.Generic.HashSet[string]]::new($scopePathComparer) +foreach ($candidate in $candidates) { + [void]$installationScopes.Add($candidate.ScopeRoot) +} + +$launchNotice = $null +if ($installationScopes.Count -gt 1) { + $launchNotice = ( + 'IntuneAssignmentChecker was found in multiple module scopes. ' + + "Using version $($selected.DisplayVersion) from '$($selected.Path)'. " + + 'Run Test-IntuneAssignmentCheckerEnvironment for the complete installation list.' + ) + if ($Check) { + Write-Warning $launchNotice + } + else { + [Environment]::SetEnvironmentVariable('IAC_LAUNCH_NOTICE', $launchNotice, 'Process') + } +} + +$loadedModule = Import-Module -Name $selected.Path -Force -PassThru -ErrorAction Stop + +if ($Check) { + [Console]::Out.WriteLine( + ('Intune Assignment Checker {0} is ready in PowerShell {1}.' -f $selected.DisplayVersion, $PSVersionTable.PSVersion) + ) + [Console]::Out.WriteLine(('Source: {0}' -f $selected.Path)) + return +} + +try { + if ($DisableMouse) { + Start-IntuneAssignmentCheckerTui -DisableMouse + } + else { + Start-IntuneAssignmentCheckerTui + } +} +finally { + if ($launchNotice) { + [Environment]::SetEnvironmentVariable('IAC_LAUNCH_NOTICE', $null, 'Process') + } +} From 70159808fca44e530e050e320ccbceb024915097 Mon Sep 17 00:00:00 2001 From: Ugur Koc <43906965+ugurkocde@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:34:11 +0200 Subject: [PATCH 13/13] ci: publish unsigned WinGet installer --- .github/workflows/windows-package.yml | 32 ++++++++------------------- Tests/Release/ModulePackage.Tests.ps1 | 12 ++++++++++ packaging/README.md | 10 ++++++--- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index cc961bb..aa185ad 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -27,9 +27,6 @@ jobs: name: Build and verify MSI runs-on: windows-latest timeout-minutes: 30 - env: - SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE }} - SIGNING_PASSWORD: ${{ secrets.WINDOWS_SIGNING_PASSWORD }} steps: - name: Checkout @@ -57,26 +54,6 @@ jobs: "product_code=$($package.ProductCode)" >> $env:GITHUB_OUTPUT $package | Format-List - - name: Require release signing credentials - if: github.event_name == 'release' - shell: pwsh - run: | - if ([string]::IsNullOrWhiteSpace($env:SIGNING_CERTIFICATE) -or [string]::IsNullOrWhiteSpace($env:SIGNING_PASSWORD)) { - throw 'WINDOWS_SIGNING_CERTIFICATE and WINDOWS_SIGNING_PASSWORD secrets are required for a release MSI.' - } - - - name: Sign release MSI - if: github.event_name == 'release' - shell: pwsh - run: | - $certificatePath = Join-Path $env:RUNNER_TEMP 'intune-assignment-checker-signing.pfx' - [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:SIGNING_CERTIFICATE)) - & signtool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com /f $certificatePath /p $env:SIGNING_PASSWORD '${{ steps.package.outputs.installer }}' - if ($LASTEXITCODE -ne 0) { throw 'Authenticode signing failed.' } - & signtool verify /pa /v '${{ steps.package.outputs.installer }}' - if ($LASTEXITCODE -ne 0) { throw 'Authenticode signature verification failed.' } - Remove-Item -LiteralPath $certificatePath -Force - - name: Verify installation and removal shell: pwsh run: | @@ -190,6 +167,15 @@ jobs: } } + - name: Reject executable artifacts + shell: pwsh + run: | + $executables = @(Get-ChildItem -LiteralPath ./artifacts -Recurse -File -Filter *.exe) + if ($executables.Count -gt 0) { + $paths = $executables.FullName -join [Environment]::NewLine + throw "Windows releases must not contain executable artifacts:$([Environment]::NewLine)$paths" + } + - name: Generate WinGet manifests shell: pwsh run: | diff --git a/Tests/Release/ModulePackage.Tests.ps1 b/Tests/Release/ModulePackage.Tests.ps1 index 26de519..a66dcb0 100644 --- a/Tests/Release/ModulePackage.Tests.ps1 +++ b/Tests/Release/ModulePackage.Tests.ps1 @@ -75,6 +75,18 @@ Describe 'IntuneAssignmentChecker release package' { (Get-Content (Join-Path $repoRoot 'packaging/README.md') -Raw) | Should -Match 'does not compile or wrap' } + It 'publishes an unsigned MSI without executable artifacts or signing secrets' { + $workflow = Get-Content (Join-Path $repoRoot '.github/workflows/windows-package.yml') -Raw + $packagingReadme = Get-Content (Join-Path $repoRoot 'packaging/README.md') -Raw + + $workflow | Should -Not -Match 'SIGNING_CERTIFICATE|SIGNING_PASSWORD|signtool|Authenticode' + $workflow | Should -Match 'Reject executable artifacts' + $workflow | Should -Match 'artifacts/\*\.msi' + $workflow | Should -Not -Match 'artifacts/\*\.exe' + $packagingReadme | Should -Match 'without Authenticode signing' + $packagingReadme | Should -Match 'does not sign or host' + } + It 'ships a PowerShell 7 command handoff without duplicating application logic' { $launcherPath = Join-Path $repoRoot 'packaging/IntuneAssignmentChecker.cmd' $bootstrapPath = Join-Path $repoRoot 'packaging/Start-IntuneAssignmentChecker.ps1' diff --git a/packaging/README.md b/packaging/README.md index 040288f..4fbb8bc 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -31,7 +31,11 @@ dotnet tool install --global wix --version 6.0.2 ./packaging/Build-WindowsInstaller.ps1 ``` -The release workflow signs the MSI, emits an SBOM and provenance attestation, -and generates versioned WinGet manifests after the signed artifact hash is known. -The generated manifest directory is ready for `winget validate` and submission to +The Windows MSI is intentionally published without Authenticode signing. The +release workflow emits the unsigned MSI, an SBOM, a provenance attestation, and +versioned WinGet manifests using the final artifact hash. It rejects `.exe` +artifacts before anything is uploaded. The MSI remains a versioned GitHub Release +asset because WinGet manifests require a publisher-hosted HTTPS `InstallerUrl`; +WinGet validates and installs the package but does not sign or host it. The +generated manifest directory is ready for `winget validate` and submission to `microsoft/winget-pkgs`.