Private/Presets.ps1
|
# Private\Presets.ps1 # --------------------------------------------------------------------------- # PRIVATE helpers for JSON-backed resource presets used by New-UTCMSnapshot # - Get-UTCMPresetSearchPaths (where to read JSON presets from) # - Get-UTCMResourcePresets (load + merge presets) # - Resolve-UTCMResources (resolve explicit list OR a preset) # Optional (warn-only validator for resource type allow-list coverage): # - Get-UTCMAllowListPath # - Test-UTCMResourceTypes # --------------------------------------------------------------------------- # Returns the search order for the JSON preset file(s): # Module-shipped -> Machine override -> User override function Get-UTCMPresetSearchPaths { [CmdletBinding()] param() # This script is in <ModuleRoot>\Private, so module root is the parent folder $moduleRoot = Split-Path -Path $PSScriptRoot -Parent Join-Path $moduleRoot 'Presets\resource-presets.json' if ($IsWindows -and $env:ProgramData) { Join-Path $env:ProgramData 'UTCM.Tools\resource-presets.json' } if ($env:AppData) { Join-Path $env:AppData 'UTCM.Tools\resource-presets.json' } } function Test-UTCMMachinePresetTrust { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path ) if (-not $IsWindows) { throw 'Machine-wide UTCM presets require Windows ownership and ACL validation.' } $trusted = @( 'S-1-5-18' # SYSTEM 'S-1-5-32-544' # Administrators 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' # TrustedInstaller ) $file = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if ($file.PSIsContainer) { throw "Machine preset '$Path' must be a file." } $contentPaths = @($file.FullName, (Split-Path -Path $file.FullName -Parent)) $current = $file.FullName while ($current) { $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Machine preset '$Path' is not trusted: reparse point '$current' is not allowed." } $acl = Get-Acl -LiteralPath $current -ErrorAction Stop $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value if ($owner -notin $trusted) { throw "Machine preset '$Path' is not trusted: '$current' must be owned by Administrators, SYSTEM, or TrustedInstaller." } $unsafe = [int64][System.Security.AccessControl.FileSystemRights]'Delete, DeleteSubdirectoriesAndFiles, ChangePermissions, TakeOwnership' if ($current -in $contentPaths) { $unsafe = $unsafe -bor [int64][System.Security.AccessControl.FileSystemRights]::Write } # Generic rights may be present in an ACL without being mapped to file-system rights. $unsafe = $unsafe -bor 0x10000000 -bor 0x40000000 foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or ($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly)) { continue } if ($rule.IdentityReference.Value -notin $trusted -and ([int64]$rule.FileSystemRights -band $unsafe)) { throw "Machine preset '$Path' is not trusted: '$current' grants write, replacement, or permission-changing access to an untrusted principal." } } $current = Split-Path -Path $current -Parent } return $true } # OPTIONAL: return the allow-list file for warn-only validation function Get-UTCMAllowListPath { [CmdletBinding()] param() $moduleRoot = Split-Path -Path $PSScriptRoot -Parent (Join-Path $moduleRoot 'Presets\supported-resource-types.json') } # Built-in tiny fallback if JSON is missing (keeps module functional) # Feel free to adjust the default preset names/contents. $script:BuiltInResourcePresets = [ordered]@{ ExchangeCore = @( 'microsoft.exchange.sharedmailbox', 'microsoft.exchange.transportrule' ) TenantCore = @( 'microsoft.entra.securitydefaults', 'microsoft.entra.namedlocationpolicy', 'microsoft.entra.authorizationpolicy', 'microsoft.entra.tenantdetails', 'microsoft.entra.conditionalaccesspolicy', 'microsoft.exchange.sharedmailbox', 'microsoft.exchange.transportrule', 'microsoft.teams.meetingpolicy', 'microsoft.teams.messagingpolicy', 'microsoft.teams.appsetuppolicy', 'microsoft.intune.devicecategory', 'microsoft.intune.policysets', 'microsoft.securityandcompliance.labelpolicy', 'microsoft.securityandcompliance.dlpcompliancepolicy', 'microsoft.securityandcompliance.protectionalert' ) } # Load and merge resource presets from JSON (module, machine, user) with built-in defaults. # OUTPUT: OrderedDictionary Name -> string[] resource identifiers function Get-UTCMResourcePresets { [CmdletBinding()] param( [string[]] $SearchPaths = (Get-UTCMPresetSearchPaths) ) # Keep insertion order; OrderedDictionary exposes .Contains(key), not .ContainsKey() $presets = [ordered]@{} + $script:BuiltInResourcePresets $modulePreset = [IO.Path]::GetFullPath((Join-Path (Split-Path $PSScriptRoot -Parent) 'Presets\resource-presets.json')) $machinePreset = if ($IsWindows -and $env:ProgramData) { [IO.Path]::GetFullPath((Join-Path $env:ProgramData 'UTCM.Tools\resource-presets.json')) } foreach ($path in $SearchPaths) { if (Test-Path -LiteralPath $path) { $fullPath = [IO.Path]::GetFullPath($path) if ($machinePreset -and $fullPath -eq $machinePreset) { Test-UTCMMachinePresetTrust -Path $fullPath | Out-Null } try { $json = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 16 -ErrorAction Stop if ($null -ne $json -and $null -ne $json.presets) { $p = $json.presets # If it's already a dictionary, use .Keys; otherwise iterate PSCustomObject properties. if ($p -is [System.Collections.IDictionary]) { foreach ($key in $p.Keys) { if ($fullPath -ne $modulePreset -and $presets.Contains($key)) { Write-Warning "UTCM preset '$key' is overridden by '$path'. Review the effective resource list before creating a snapshot." } $presets[$key] = @( $p[$key] | ForEach-Object { [string]$_ } | Select-Object -Unique ) } } else { foreach ($prop in $p.PSObject.Properties) { if ($fullPath -ne $modulePreset -and $presets.Contains($prop.Name)) { Write-Warning "UTCM preset '$($prop.Name)' is overridden by '$path'. Review the effective resource list before creating a snapshot." } $presets[$prop.Name] = @( $prop.Value | ForEach-Object { [string]$_ } | Select-Object -Unique ) } } } } catch { Write-Warning "Failed to load UTCM presets from '$path': $($_.Exception.Message)" } } } return $presets } # Resolve effective resources from an explicit list or a named preset (backed by JSON). # OUTPUT: string[] resource identifiers (non-empty) function Resolve-UTCMResources { [CmdletBinding()] param( [string[]] $Resources, [string] $Preset = 'TenantCore', [string[]] $PresetSearchPaths = (Get-UTCMPresetSearchPaths) ) if ($Resources -and $Resources.Count -gt 0) { return @($Resources | ForEach-Object { [string]$_ }) } $presets = Get-UTCMResourcePresets -SearchPaths $PresetSearchPaths # OrderedDictionary has .Contains(key) if (-not $presets.Contains($Preset)) { $known = ($presets.Keys | Sort-Object) -join ', ' throw "Unknown preset '$Preset'. Known presets: $known" } $resolved = @($presets[$Preset] | ForEach-Object { [string]$_ }) if (-not $resolved -or $resolved.Count -eq 0) { throw "Preset '$Preset' resolved to an empty resource list." } return $resolved } # OPTIONAL (warn-only): validate resource identifiers against a local allow-list JSON function Test-UTCMResourceTypes { [CmdletBinding()] param( [Parameter(Mandatory)][string[]] $Resources, [string] $AllowListPath = (Get-UTCMAllowListPath) ) if (Test-Path $AllowListPath) { try { $allow = Get-Content -Path $AllowListPath -Raw | ConvertFrom-Json -Depth 8 $allowed = @([string[]]$allow.resourceTypes) if ($allowed.Count -gt 0) { $invalid = $Resources | Where-Object { $_ -notin $allowed } if ($invalid) { Write-Warning ("The following UTCM resource type(s) may not be supported by the current allow-list: {0}" -f ($invalid -join ', ')) return $false } } } catch { Write-Warning "Failed to parse allow-list '$AllowListPath': $($_.Exception.Message)" } } else { Write-Verbose "Allow-list not found at '$AllowListPath'; skipping validation." } return $true } |