Private/AssignmentSnapshot.ps1
|
function Get-IACAssignmentRecordPropertyNames { [CmdletBinding()] param() @( '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' ) } function Get-IACOrdinalSortedUniqueString { [CmdletBinding()] param( [AllowEmptyCollection()][object[]]$InputObject = @() ) $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($item in @($InputObject)) { if ($null -eq $item) { continue } [void]$set.Add("$item") } $list = [System.Collections.Generic.List[string]]::new() foreach ($item in $set) { [void]$list.Add($item) } $list.Sort([System.StringComparer]::Ordinal) return $list.ToArray() } function Get-IACOrdinalSortedObject { [CmdletBinding()] param( [AllowEmptyCollection()][object[]]$InputObject = @(), [Parameter(Mandatory)][scriptblock]$KeySelector ) $entries = [System.Collections.Generic.List[object]]::new() $index = 0 foreach ($item in @($InputObject)) { if ($null -eq $item) { continue } $key = $item | ForEach-Object $KeySelector [void]$entries.Add([PSCustomObject]@{ Key = "$key"; Index = $index; Value = $item }) $index++ } $entries.Sort([System.Comparison[object]]{ param($left, $right) $comparison = [System.StringComparer]::Ordinal.Compare($left.Key, $right.Key) if ($comparison -ne 0) { return $comparison } return $left.Index.CompareTo($right.Index) }) return @($entries | ForEach-Object Value) } function ConvertTo-IACSnapshotRecord { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] $InputObject ) process { $requiredProperties = Get-IACAssignmentRecordPropertyNames $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 ([string]::IsNullOrWhiteSpace("$($InputObject.CategoryId)")) { throw 'Snapshot input contains an assignment record without CategoryId.' } $assignmentMode = switch ("$($InputObject.AssignmentMode)".ToLowerInvariant()) { 'include' { 'Include' } 'exclude' { 'Exclude' } 'none' { 'None' } 'unknown' { 'Unknown' } default { $null } } if ($null -eq $assignmentMode) { throw "Snapshot input contains unsupported AssignmentMode '$($InputObject.AssignmentMode)'." } $targetType = switch ("$($InputObject.TargetType)".ToLowerInvariant()) { 'allusers' { 'AllUsers' } 'alldevices' { 'AllDevices' } 'group' { 'Group' } 'none' { 'None' } 'unknown' { 'Unknown' } default { $null } } if ($null -eq $targetType) { throw "Snapshot input contains unsupported TargetType '$($InputObject.TargetType)'." } $effectiveState = if ([string]::IsNullOrWhiteSpace("$($InputObject.EffectiveState)")) { $null } else { switch ("$($InputObject.EffectiveState)".ToLowerInvariant()) { 'included' { 'Included' } 'excluded' { 'Excluded' } 'nottargeted' { 'NotTargeted' } 'unknown' { 'Unknown' } default { $null } } } if (-not [string]::IsNullOrWhiteSpace("$($InputObject.EffectiveState)") -and $null -eq $effectiveState) { throw "Snapshot input contains unsupported EffectiveState '$($InputObject.EffectiveState)'." } $reasonChain = foreach ($reason in @($InputObject.ReasonChain | Where-Object { $null -ne $_ })) { $sequence = 0 if (-not [int]::TryParse("$($reason.Sequence)", [ref]$sequence) -or $sequence -lt 0) { throw "Snapshot input contains a reason-chain entry with invalid Sequence '$($reason.Sequence)'." } [PSCustomObject][ordered]@{ Sequence = $sequence Code = $reason.Code FilterCode = $reason.FilterCode Outcome = $reason.Outcome AssignmentId = $reason.AssignmentId AssignmentMode = $reason.AssignmentMode TargetType = $reason.TargetType TargetId = $reason.TargetId MembershipSources = @(Get-IACOrdinalSortedUniqueString -InputObject @($reason.MembershipSources)) TargetResult = $reason.TargetResult FilterId = $reason.FilterId FilterMode = $reason.FilterMode FilterResult = $reason.FilterResult Message = $reason.Message } } [PSCustomObject][ordered]@{ SchemaVersion = 1 TenantId = $InputObject.TenantId TenantName = $InputObject.TenantName SubjectType = $InputObject.SubjectType SubjectId = $InputObject.SubjectId SubjectName = $InputObject.SubjectName CategoryId = $InputObject.CategoryId Category = $InputObject.Category PolicyId = $InputObject.PolicyId PolicyName = $InputObject.PolicyName Platform = $InputObject.Platform ScopeTagIds = @(Get-IACOrdinalSortedUniqueString -InputObject @($InputObject.ScopeTagIds)) ScopeTags = @(Get-IACOrdinalSortedUniqueString -InputObject @($InputObject.ScopeTags)) AssignmentId = $InputObject.AssignmentId AssignmentMode = $assignmentMode TargetType = $targetType TargetId = $InputObject.TargetId TargetName = $InputObject.TargetName Intent = $InputObject.Intent FilterId = $InputObject.FilterId FilterName = $InputObject.FilterName FilterMode = $InputObject.FilterMode FilterRule = $InputObject.FilterRule FilterPlatform = $InputObject.FilterPlatform EffectiveState = $effectiveState ReasonChain = @(Get-IACOrdinalSortedObject -InputObject @($reasonChain) -KeySelector { '{0:D10}|{1}' -f $_.Sequence, (ConvertTo-IACIdentityComponent $_.Code) }) AssignmentReason = $InputObject.AssignmentReason Source = $InputObject.Source } } } function ConvertTo-IACIdentityComponent { param([AllowNull()]$Value) $bytes = [System.Text.Encoding]::UTF8.GetBytes("$Value") return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } function Get-IACAssignmentIdentityKey { [CmdletBinding()] param( [Parameter(Mandatory)] $Record ) $assignmentComponent = if (-not [string]::IsNullOrWhiteSpace("$($Record.AssignmentId)")) { "id:$($Record.AssignmentId)" } elseif ($Record.AssignmentMode -eq 'None') { 'none' } else { "fallback:$($Record.AssignmentMode)|$($Record.TargetType)|$($Record.TargetId)|$($Record.Intent)" } return 'v1:{0}:{1}:{2}:{3}:{4}' -f @( ConvertTo-IACIdentityComponent $Record.SubjectType ConvertTo-IACIdentityComponent $Record.SubjectId ConvertTo-IACIdentityComponent $Record.CategoryId ConvertTo-IACIdentityComponent $Record.PolicyId ConvertTo-IACIdentityComponent $assignmentComponent ) } function Get-IACInstalledModuleVersion { [CmdletBinding()] param() $loadedModule = Get-Module -Name IntuneAssignmentChecker | Select-Object -First 1 if ($loadedModule -and $loadedModule.Version) { return $loadedModule.Version.ToString() } $manifestPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'IntuneAssignmentChecker.psd1' $manifestData = Import-PowerShellDataFile -LiteralPath $manifestPath -ErrorAction Stop if ([string]::IsNullOrWhiteSpace("$($manifestData.ModuleVersion)")) { throw "Module manifest '$manifestPath' does not declare ModuleVersion." } return "$($manifestData.ModuleVersion)" } function New-IACAssignmentSnapshot { [CmdletBinding()] param( [AllowEmptyCollection()][object[]]$Records = @(), [Parameter(Mandatory)][datetimeoffset]$CapturedAtUtc, [AllowEmptyCollection()][object[]]$CoverageCategories = @(), [AllowEmptyCollection()][object[]]$CoverageErrors = @(), [Parameter(Mandatory)][bool]$CoverageComplete, [Parameter(Mandatory)][string]$CoverageMode ) $canonicalRecords = [System.Collections.Generic.List[object]]::new() foreach ($record in @($Records)) { [void]$canonicalRecords.Add((ConvertTo-IACSnapshotRecord -InputObject $record)) } $recordsByKey = [System.Collections.Generic.SortedDictionary[string, object]]::new([System.StringComparer]::Ordinal) foreach ($record in $canonicalRecords) { $identityKey = Get-IACAssignmentIdentityKey -Record $record if ($recordsByKey.ContainsKey($identityKey)) { throw "Snapshot contains duplicate assignment identity key '$identityKey'." } $recordsByKey.Add($identityKey, $record) } $orderedRecords = @($recordsByKey.Values) $tenantIds = @(Get-IACOrdinalSortedUniqueString -InputObject @( $orderedRecords.TenantId | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") } )) if ($tenantIds.Count -gt 1) { throw 'Snapshot records contain more than one tenant ID.' } if ($script:CurrentTenantId -and $tenantIds.Count -eq 1 -and "$script:CurrentTenantId" -ne "$($tenantIds[0])") { throw "Snapshot record tenant '$($tenantIds[0])' does not match the connected tenant '$script:CurrentTenantId'." } $tenantNames = @(Get-IACOrdinalSortedUniqueString -InputObject @( $orderedRecords.TenantName | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") } )) $resolvedTenantId = if ($script:CurrentTenantId) { "$script:CurrentTenantId" } elseif ($tenantIds.Count -eq 1) { "$($tenantIds[0])" } else { $null } if ([string]::IsNullOrWhiteSpace($resolvedTenantId)) { throw 'A tenant ID is required to export an assignment snapshot; connect first or supply records with TenantId.' } [PSCustomObject][ordered]@{ SchemaName = 'IntuneAssignmentChecker.AssignmentSnapshot' SchemaVersion = 1 CapturedAtUtc = $CapturedAtUtc.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ', [System.Globalization.CultureInfo]::InvariantCulture) ModuleVersion = Get-IACInstalledModuleVersion Tenant = [PSCustomObject][ordered]@{ Id = $resolvedTenantId Name = if ($script:CurrentTenantName) { "$script:CurrentTenantName" } elseif ($tenantNames.Count -eq 1) { "$($tenantNames[0])" } else { $null } } Coverage = [PSCustomObject][ordered]@{ Mode = $CoverageMode Complete = $CoverageComplete RecordCount = $orderedRecords.Count Categories = @(Get-IACOrdinalSortedObject -InputObject @($CoverageCategories) -KeySelector { '{0}|{1}' -f (ConvertTo-IACIdentityComponent $_.CategoryId), (ConvertTo-IACIdentityComponent $_.DisplayName) }) Errors = @(Get-IACOrdinalSortedObject -InputObject @($CoverageErrors) -KeySelector { '{0}|{1}' -f (ConvertTo-IACIdentityComponent $_.CategoryId), (ConvertTo-IACIdentityComponent $_.Message) }) } Records = $orderedRecords } } function Write-IACAssignmentSnapshot { [CmdletBinding()] param( [Parameter(Mandatory)]$Snapshot, [Parameter(Mandatory)][string]$Path ) $json = $Snapshot | ConvertTo-Json -Depth 20 $json = ($json -replace "`r`n", "`n").TrimEnd("`r", "`n") + "`n" [System.IO.File]::WriteAllText($Path, $json, [System.Text.UTF8Encoding]::new($false)) } function Read-IACAssignmentSnapshot { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Assignment snapshot '$Path' does not exist." } try { $snapshot = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 20 -ErrorAction Stop } catch { throw "Assignment snapshot '$Path' is not valid JSON: $($_.Exception.Message)" } if ($snapshot -is [array] -or $null -eq $snapshot) { throw "Assignment snapshot '$Path' must contain one JSON object." } if ($snapshot.SchemaName -ne 'IntuneAssignmentChecker.AssignmentSnapshot') { 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." } foreach ($property in @('CapturedAtUtc', 'ModuleVersion', 'Tenant', 'Coverage', 'Records')) { if ($null -eq $snapshot.PSObject.Properties[$property]) { throw "Assignment snapshot '$Path' is malformed: missing '$property'." } } $captured = [datetimeoffset]::MinValue if (-not [datetimeoffset]::TryParse("$($snapshot.CapturedAtUtc)", [ref]$captured)) { throw "Assignment snapshot '$Path' has an invalid CapturedAtUtc value." } if ("$($snapshot.ModuleVersion)" -notmatch '^\d+(?:\.\d+){1,3}(?:[-+].+)?$') { throw "Assignment snapshot '$Path' has an invalid ModuleVersion value." } if ($null -eq $snapshot.Tenant -or $null -eq $snapshot.Coverage -or $null -eq $snapshot.Records) { throw "Assignment snapshot '$Path' is malformed: Tenant, Coverage, and Records cannot be null." } foreach ($property in @('Id', 'Name')) { if ($null -eq $snapshot.Tenant.PSObject.Properties[$property]) { throw "Assignment snapshot '$Path' is malformed: Tenant is missing '$property'." } } if ([string]::IsNullOrWhiteSpace("$($snapshot.Tenant.Id)")) { throw "Assignment snapshot '$Path' is malformed: Tenant.Id cannot be empty." } foreach ($property in @('Mode', 'Complete', 'RecordCount', 'Categories', 'Errors')) { if ($null -eq $snapshot.Coverage.PSObject.Properties[$property]) { throw "Assignment snapshot '$Path' is malformed: Coverage is missing '$property'." } } if ($snapshot.Coverage.Complete -isnot [bool]) { throw "Assignment snapshot '$Path' is malformed: Coverage.Complete must be a Boolean." } if ($snapshot.Coverage.Mode -cnotin @('TenantScan', 'ProvidedRecords')) { throw "Assignment snapshot '$Path' is malformed: unsupported Coverage.Mode '$($snapshot.Coverage.Mode)'." } $recordCount = 0 if (-not [int]::TryParse("$($snapshot.Coverage.RecordCount)", [ref]$recordCount) -or $recordCount -lt 0) { throw "Assignment snapshot '$Path' is malformed: Coverage.RecordCount must be a non-negative integer." } if ($null -eq $snapshot.Coverage.Categories -or $null -eq $snapshot.Coverage.Errors) { throw "Assignment snapshot '$Path' is malformed: Coverage.Categories and Coverage.Errors cannot be null." } foreach ($category in @($snapshot.Coverage.Categories)) { foreach ($property in @('CategoryId', 'DisplayName', 'Status', 'RecordCount')) { if ($null -eq $category.PSObject.Properties[$property]) { throw "Assignment snapshot '$Path' is malformed: a coverage category is missing '$property'." } } if ([string]::IsNullOrWhiteSpace("$($category.CategoryId)")) { throw "Assignment snapshot '$Path' is malformed: a coverage category has no CategoryId." } if ($category.Status -cnotin @('Captured', 'Provided', 'Failed', 'Skipped', 'Unknown')) { throw "Assignment snapshot '$Path' is malformed: unsupported coverage status '$($category.Status)'." } $categoryRecordCount = 0 if (-not [int]::TryParse("$($category.RecordCount)", [ref]$categoryRecordCount) -or $categoryRecordCount -lt 0) { throw "Assignment snapshot '$Path' is malformed: a coverage category has an invalid RecordCount." } } foreach ($coverageError in @($snapshot.Coverage.Errors)) { foreach ($property in @('CategoryId', 'Message')) { if ($null -eq $coverageError.PSObject.Properties[$property]) { throw "Assignment snapshot '$Path' is malformed: a coverage error is missing '$property'." } } if ([string]::IsNullOrWhiteSpace("$($coverageError.CategoryId)") -or [string]::IsNullOrWhiteSpace("$($coverageError.Message)")) { throw "Assignment snapshot '$Path' is malformed: coverage errors require CategoryId and Message." } } $validatedRecords = [System.Collections.Generic.List[object]]::new() foreach ($record in @($snapshot.Records)) { try { [void]$validatedRecords.Add((ConvertTo-IACSnapshotRecord -InputObject $record)) } catch { throw "Assignment snapshot '$Path' contains an invalid record: $($_.Exception.Message)" } } if ($recordCount -ne $validatedRecords.Count) { throw "Assignment snapshot '$Path' record count does not match Coverage.RecordCount." } $coverageByCategory = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) foreach ($category in @($snapshot.Coverage.Categories)) { if ($coverageByCategory.ContainsKey("$($category.CategoryId)")) { throw "Assignment snapshot '$Path' contains duplicate coverage category '$($category.CategoryId)'." } $coverageByCategory.Add("$($category.CategoryId)", $category) } foreach ($record in $validatedRecords) { if (-not $coverageByCategory.ContainsKey("$($record.CategoryId)")) { throw "Assignment snapshot '$Path' contains record category '$($record.CategoryId)' outside declared coverage." } } foreach ($categoryId in $coverageByCategory.Keys) { $actualCount = @($validatedRecords | Where-Object CategoryId -CEQ $categoryId).Count if ([int]$coverageByCategory[$categoryId].RecordCount -ne $actualCount) { throw "Assignment snapshot '$Path' has an incorrect RecordCount for coverage category '$categoryId'." } } foreach ($coverageError in @($snapshot.Coverage.Errors)) { if (-not $coverageByCategory.ContainsKey("$($coverageError.CategoryId)")) { throw "Assignment snapshot '$Path' contains a coverage error outside declared categories." } } if ([bool]$snapshot.Coverage.Complete -and (@($snapshot.Coverage.Errors).Count -gt 0 -or @($snapshot.Coverage.Categories | Where-Object Status -in @('Failed', 'Skipped', 'Unknown')).Count -gt 0)) { throw "Assignment snapshot '$Path' is malformed: complete coverage cannot contain failed, skipped, or unknown categories." } $recordsByKey = [System.Collections.Generic.SortedDictionary[string, object]]::new([System.StringComparer]::Ordinal) foreach ($record in $validatedRecords) { $identityKey = Get-IACAssignmentIdentityKey -Record $record if ($recordsByKey.ContainsKey($identityKey)) { throw "Assignment snapshot '$Path' contains duplicate identity key '$identityKey'." } $recordsByKey.Add($identityKey, $record) } $snapshot.Records = @($recordsByKey.Values) return $snapshot } |