Files
IntuneManagement/Internal/Settings.ps1
T

664 lines
24 KiB
PowerShell

# Settings store management.
#
# Settings are three layers, and this file is the middle one:
#
# storage Internal/Core.ps1 - Get/Save/Remove-SettingStoreValue. PATH
# addressed: the caller supplies the SubPath, and whether that path
# means a registry key, a node in a JSON tree or a node in an
# in-memory tree is decided here.
# resolver this file - KEY addressed. Get-SettingValue (in Core.ps1, because
# Write-Log needs it during preload) and its write-side counterparts
# look the key up in the registered definitions and derive the
# SubPath from it, so no caller has to know a path.
# public Public/*-IMSetting*.ps1 - the exported api.
#
# The store has three modes, resolved once at the top of Internal/Core.ps1:
#
# Registry HKCU:\Software\IntuneManagement. Windows only.
# Json a settings file: IM_SETTINGS_FILE if set, else
# LocalApplicationData/IntuneManagement/Settings.json. The default
# off Windows.
# Memory a settings tree with no file behind it. Reads and writes work
# normally and nothing touches disk. For automation (a runbook on a
# shared worker must not write to that worker's HKCU) and for tests.
#
# Memory mode is not a fourth code path: the storage primitives take their JSON
# branch whenever a settings object exists, and persist only when a settings FILE
# exists as well. Memory mode is an object with no file - see the condition in
# Save-SettingStoreValue.
#
# See Docs/Settings.md.
# IM_SETTINGS_STORE was set to something unrecognized - Core.ps1 fell back to the
# platform default, silently, because logging does not work that early. Say so now
# rather than have a runbook believe it opted out of disk writes.
if($script:SettingsStoreModeEnvRequest -and
$script:SettingsStoreModeEnvRequest -notin @("Memory", "Json", "Registry"))
{
Write-Log "IM_SETTINGS_STORE='$script:SettingsStoreModeEnvRequest' is not a valid store (Memory, Json, Registry). Using '$script:SettingsStoreMode'." 2
}
# What the store actually is right now. Derived from the state variables rather
# than reported from $script:SettingsStoreMode alone, because the mode is a
# request and the state is the outcome: Initialize-JsonSettings falls back to the
# registry when the file cannot be read, and off Windows that leaves no store at
# all. Callers (and Get-IMSettingsStore) need the outcome.
function Get-SettingsStoreInfo
{
$persisted = $true
$path = $null
if($script:JsonSettingsObj -and $script:JSonSettingFile)
{
$mode = "Json"
$path = $script:JSonSettingFile
}
elseif($script:JsonSettingsObj)
{
$mode = "Memory"
$persisted = $false
}
elseif($script:IsWindowsOS)
{
$mode = "Registry"
$path = Get-RegPath
}
else
{
# No settings file and no registry provider: reads fall back to the
# registered defaults and writes go nowhere. Only reachable when the JSON
# file failed to load off Windows.
$mode = "None"
$persisted = $false
}
[PSCustomObject]@{
Mode = $mode
Path = $path
Persisted = $persisted
ValueCount = @(Get-SettingsStoreEntries).Count
# The store that was asked for, NOT $script:SettingsStoreMode: the JSON
# fallback rewrites that variable to "Registry", so reporting it here made
# Mode and RequestedMode agree in exactly the case a caller needs them to
# differ - the one where the requested store failed to load.
RequestedMode = $script:SettingsStoreModeRequested
}
}
# Every value in the store as flat SubPath/Key/Value rows. Reading the registry
# recursively is the same walk Export-Settings already does, so both directions
# reuse Add-RegKeyToSettings and this only has to flatten one shape.
function Get-SettingsStoreEntries
{
$tree = $script:JsonSettingsObj
if(-not $tree) { $tree = Get-PersistedSettingsTree }
if(-not $tree) { return @() }
Get-SettingsTreeEntries $tree ""
}
# Recursive half of Get-SettingsStoreEntries. A nested object is a SubPath level;
# anything else is a value.
function Get-SettingsTreeEntries
{
param($Node, [string]$SubPath)
$entries = @()
foreach($prop in $Node.PSObject.Properties)
{
# Exact type name, not `-is [PSCustomObject]`: that accelerator resolves to
# PSObject, which almost everything satisfies once PowerShell has wrapped
# it, so every leaf value would be walked as a subpath.
if($null -ne $prop.Value -and $prop.Value.GetType().FullName -eq "System.Management.Automation.PSCustomObject")
{
$child = if($SubPath) { "$SubPath\$($prop.Name)" } else { $prop.Name }
$entries += Get-SettingsTreeEntries $prop.Value $child
}
else
{
$entries += [PSCustomObject]@{
SubPath = $SubPath
Key = $prop.Name
Value = $prop.Value
}
}
}
return $entries
}
# The persisted store as a nested PSCustomObject tree, whatever the platform.
# ConvertTo-Json/ConvertFrom-Json is the conversion, not a shortcut: it is exactly
# how Initialize-JsonSettings builds its object, so a seeded memory tree and a
# loaded file tree have the same shape and the same walk works on both.
function Get-PersistedSettingsTree
{
if($script:JSonSettingFile -and [IO.File]::Exists($script:JSonSettingFile))
{
try
{
return (ConvertFrom-Json ([IO.File]::ReadAllText($script:JSonSettingFile)))
}
catch
{
Write-LogError "Failed to read settings file $script:JSonSettingFile" $_.Exception
return $null
}
}
if($script:IsWindowsOS)
{
$settingObj = [ordered]@{}
Add-RegKeyToSettings $settingObj (Get-RegPath)
return ($settingObj | ConvertTo-Json -Depth 20 | ConvertFrom-Json)
}
return $null
}
# Switch to memory mode. -Seed copies the persisted values in first, so a session
# starts from the user's real configuration and then diverges without writing
# back; without it the store starts empty and every unset key resolves to its
# registered default.
function Initialize-MemorySettings
{
[CmdletBinding()]
param([switch]$Seed)
$tree = $null
if($Seed)
{
$tree = Get-PersistedSettingsTree
if(-not $tree) { Write-Log "No persisted settings to seed the in-memory store from" 2 }
}
if(-not $tree) { $tree = [PSCustomObject]@{} }
# Order matters: null the file first so a write between the two assignments
# cannot persist into the store we are leaving.
$script:JSonSettingFile = $null
$script:JsonSettingsObj = $tree
$script:SettingsStoreMode = "Memory"
$script:SettingsStoreModeRequested = "Memory"
Write-Log "Settings store is now in memory$(if($Seed) { " (seeded with $(@(Get-SettingsStoreEntries).Count) values)" }). Nothing will be written to disk."
}
# Change the store at runtime. The public Use-IMSettingsStore is a thin wrapper.
function Set-SettingsStoreMode
{
[CmdletBinding()]
param(
[ValidateSet("Memory", "Json", "Registry")]
[string]$Mode,
[string]$Path,
[switch]$Seed
)
if($Mode -eq "Memory")
{
Initialize-MemorySettings -Seed:$Seed
return
}
if($Mode -eq "Registry")
{
if(-not $script:IsWindowsOS)
{
Write-LogError "The registry settings store is only available on Windows"
return
}
$script:JsonSettingsObj = $null
$script:JSonSettingFile = $null
$script:SettingsStoreMode = "Registry"
$script:SettingsStoreModeRequested = "Registry"
Write-Log "Settings store is now the registry: $(Get-RegPath)"
return
}
# Json. A caller-supplied path replaces whatever file is loaded;
# Initialize-JsonSettings creates it when it does not exist.
$script:JsonSettingsObj = $null
$script:JSonSettingFile = $Path
$script:SettingsStoreMode = "Json"
# Recorded BEFORE Initialize-JsonSettings, so a load failure - which calls
# Clear-JsonSettingsValues and reverts the mode - still leaves "Json" as the
# answer to "what did the caller ask for?".
$script:SettingsStoreModeRequested = "Json"
Initialize-JsonSettings
if(-not $script:JsonSettingsObj)
{
# Initialize-JsonSettings already logged, and Clear-JsonSettingsValues has
# reverted the mode - do not claim success.
return
}
if($Seed -and $script:IsWindowsOS)
{
Write-Log "Seeding a settings file from the registry is what Export-Settings does; -Seed is ignored for the Json store" 2
}
}
#region Resolver - address settings by KEY, never by path
# Does a value EXIST at this path? Deliberately not Get-SettingStoreValue: that
# reports an empty string as missing, so that clearing a text box in the settings
# form restores the registered default. "Configured" has to mean "written", which
# is a different question. Generalizes Get-IsTenantSettingConfigured, which could
# only ever ask about the current tenant's path.
function Test-SettingStoreValue
{
param($SubPath = "", $Key = "")
if(-not $Key) { return $false }
if($script:JsonSettingsObj)
{
$node = Get-SettingsTreeNode $SubPath
if(-not $node) { return $false }
return ($null -ne ($node.PSObject.Properties | Where-Object Name -eq $Key))
}
if($script:IsWindowsOS)
{
try
{
$item = Get-Item -LiteralPath (Get-RegPath $SubPath) -ErrorAction Stop
return ($item.GetValueNames() -contains $Key)
}
catch
{
# Missing key. Not an error - the value simply is not configured.
}
}
return $false
}
# The store path a key lives at. This function is the whole point of the resolver
# layer: no caller outside it should ever spell a SubPath.
#
# A registered key takes its SubPath from its definition, which is what makes the
# write side agree with Get-SettingValue by construction. Every SubPath-mismatch
# bug so far (GraphPageSize, the bulk-export round-trip, DefaultCloud) was a hand-
# written path on one side only.
#
# $SubPath defaults to $null rather than "" because "" is a real path - the root of
# the store - so "not supplied" and "the root" have to stay distinguishable.
function Resolve-SettingStorePath
{
[CmdletBinding()]
param(
[string]$Key,
$Definition,
[switch]$Tenant,
[string]$TenantID,
$SubPath = $null
)
if($Definition)
{
# A REGISTERED setting is stored where its registration says, always - a
# caller-supplied -SubPath is reported and ignored rather than honoured.
# Honouring it wrote the value to a path the application never reads, which
# is precisely the path drift this layer exists to remove:
# `Set-IMSetting GraphPageSize 100 -SubPath Wrong` looked like it worked and
# changed nothing. -SubPath is for UNREGISTERED keys, where it is the only
# way to name the path - both to write and (Resolve-SettingValue) to read.
if($null -ne $SubPath -and "$SubPath" -ne "$($Definition.SubPath)")
{
Write-Log "Ignoring -SubPath '$SubPath' for '$Key': it is a registered setting and is always stored at '$($Definition.SubPath)'" 2
}
$SubPath = $Definition.SubPath
}
elseif($null -eq $SubPath)
{
Write-LogError "'$Key' is not a registered setting, so its storage path cannot be resolved. Pass -SubPath to read or write it anyway."
return $null
}
# A registered setting with no -SubPath (EnvironmentText, AppTheme, every
# General entry) stores at the ROOT of the tree, and its definition carries
# $null rather than "". Returning that $null straight through would be read as
# this function's failure sentinel by every caller, so a root-stored key could
# never be written - Set-SettingValue returned quietly and the value vanished.
if($null -eq $SubPath) { $SubPath = "" }
if(-not $Tenant) { return $SubPath }
if(-not $TenantID) { $TenantID = $script:OrganizationId }
if(-not $TenantID)
{
# Silently writing the global value instead would be the worst outcome: the
# caller asked for one tenant and would have changed every tenant.
Write-LogError "Cannot resolve a tenant-specific path for '$Key': no tenant id was supplied and no tenant is connected."
return $null
}
if($SubPath) { return "$TenantID\$SubPath" }
return $TenantID
}
# A value in the shape the store has always held it in.
#
# Everything on disk is a string: the settings form calls Save-SettingStoreValue
# with no -Type, so it takes the "String" branch and writes $Value.ToString().
# Reproducing that exactly is a hard requirement - a value written through this
# layer has to be indistinguishable from one written by the form, or the two
# disagree about the same key.
function Format-SettingStoreValue
{
param($Value, $Definition)
# $null means "remove the value" to Save-SettingStoreValue. Pass it through.
if($null -eq $Value) { return $null }
if($Definition.Type -eq "Boolean")
{
# PascalCase "True"/"False" - that is what $true.ToString() produces and
# what every existing store contains. Coerce first so $true, "true" and
# "TRUE" all land in the one canonical shape.
#
# A string is compared to "true" rather than cast: [bool]"False" is $true
# in PowerShell (any non-empty string is), which would turn every attempt
# to store False into True. This is the same test Get-SettingValue uses to
# read the value back, so writer and reader cannot disagree.
$bool = if($Value -is [string]) { $Value -eq "true" } else { [bool]$Value }
return $bool.ToString()
}
return $Value.ToString()
}
# Write a setting by key. The counterpart to Get-SettingValue, and the reason no
# caller needs to know that "GraphPageSize" lives under "IntuneManager".
#
# -Tenant writes the value for one tenant only, which is the same precedence
# Get-SettingValue reads with (tenant first, then global).
function Set-SettingValue
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Key,
[Parameter(Mandatory = $true)]
[AllowNull()]
[AllowEmptyString()]
$Value,
[switch]$Tenant,
[string]$TenantID,
$SubPath = $null,
[switch]$PassThru
)
$definition = Get-SettingDefinitionByKey $Key
$path = Resolve-SettingStorePath -Key $Key -Definition $definition -Tenant:$Tenant -TenantID $TenantID -SubPath $SubPath
if($null -eq $path) { return }
$stored = Format-SettingStoreValue $Value $definition
# Always "String": see Format-SettingStoreValue. The registered Types
# (Boolean, Int, File, List, ...) are UI editor hints, not storage types, and
# are not valid RegistryValueKind names - passing one through would throw on
# Windows.
Save-SettingStoreValue $path $Key $stored "String"
Write-LogDebug "Setting '$Key' set to '$stored' at '$path'"
# -SubPath forwarded, or -PassThru on an unregistered key writes the value and
# then fails to read back the very path it just wrote to.
if($PassThru) { Resolve-SettingValue -Key $Key -TenantID $TenantID -SubPath $SubPath }
}
# Remove a setting by key so it falls back to the next level: a tenant value
# reverts to the global one, a global value to the registered default.
function Remove-SettingValue
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Key,
[switch]$Tenant,
[string]$TenantID,
$SubPath = $null
)
$definition = Get-SettingDefinitionByKey $Key
$path = Resolve-SettingStorePath -Key $Key -Definition $definition -Tenant:$Tenant -TenantID $TenantID -SubPath $SubPath
if($null -eq $path) { return }
Remove-SettingStoreValue $path $Key
Write-LogDebug "Setting '$Key' removed from '$path'"
}
# Is this key written at the level asked about? Not "does it have a value" - an
# unconfigured key always has a value, its registered default.
function Test-SettingValueConfigured
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Key,
[switch]$Tenant,
[string]$TenantID,
$SubPath = $null
)
$definition = Get-SettingDefinitionByKey $Key
$path = Resolve-SettingStorePath -Key $Key -Definition $definition -Tenant:$Tenant -TenantID $TenantID -SubPath $SubPath
if($null -eq $path) { return $false }
Test-SettingStoreValue $path $Key
}
# The value AND where it came from. Get-SettingValue answers "what is it"; this
# answers "and why", which is what a runbook needs before it decides to change
# something and what the settings form needs to show a tenant override.
#
# Computed fresh from the store every call. Get-SettingValue caches its last read
# on the definition object as .Value, which is a per-session artifact of whoever
# read it last (and with which -TenantID) - not a fact about the store.
function Resolve-SettingValue
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Key,
[string]$TenantID,
[switch]$GlobalOnly,
[switch]$TenantOnly,
$SubPath = $null
)
$definition = Get-SettingDefinitionByKey $Key
$registered = $null -ne $definition
if(-not $registered)
{
# No registration means no SubPath, no Type and no default. The caller can
# supply the path instead, which is what makes a hidden key (ExportReplaceTokens,
# per-feature state) readable through the same resolver - and therefore through
# Get-IMSetting - rather than only writable. Without this the write side accepted
# -SubPath and the read side had no way to address what it had just written.
if($null -eq $SubPath)
{
Write-LogError "'$Key' is not a registered setting, so it has no definition to resolve against. Pass -SubPath to read it anyway."
return
}
# Stands in for a registration so the rest of this function, and
# Resolve-SettingStorePath, need no unregistered-key branch. Type and
# DefaultValue stay $null: an unregistered key has no declared type to
# normalize to and no default to fall back on.
$definition = [PSCustomObject]@{
Key = $Key
Title = $null
Type = $null
DefaultValue = $null
SubPath = $SubPath
Section = $null
}
}
if(-not $TenantID -and $script:OrganizationId) { $TenantID = $script:OrganizationId }
# A tenant-scoped read with no tenant to read against cannot be answered.
# Falling through would have reported the registered default as though it were
# the tenant's value, so a caller checking "does this tenant override the
# setting?" got a confident answer while signed out. The write side already
# refuses this case (Resolve-SettingStorePath), so refusing it here keeps the
# two consistent.
if($TenantOnly -eq $true -and -not $TenantID)
{
Write-LogError "Cannot resolve the tenant value of '$Key': no tenant id was supplied and no tenant is connected."
return
}
$value = $null
$source = "Default"
# Both paths come from Resolve-SettingStorePath rather than being spelled here,
# so this reader cannot drift from the writer - including for a root-stored key,
# where composing the tenant path by hand leaves a trailing separator.
if($GlobalOnly -ne $true -and $TenantID)
{
$tenantPath = Resolve-SettingStorePath -Key $Key -Definition $definition -Tenant -TenantID $TenantID -SubPath $SubPath
if($null -ne $tenantPath)
{
$value = Get-SettingStoreValue $tenantPath $definition.Key
if($null -ne $value) { $source = "Tenant" }
}
}
if($null -eq $value -and $TenantOnly -ne $true)
{
$value = Get-SettingStoreValue (Resolve-SettingStorePath -Key $Key -Definition $definition -SubPath $SubPath) $definition.Key
if($null -ne $value) { $source = "Global" }
}
# A scoped read reports the ABSENCE of a value at that scope as $null/"NotSet",
# never as the registered default: "this tenant does not override the setting"
# and "this tenant overrides it to the same value as the default" are different
# facts, and substituting the default made them indistinguishable. Only an
# unscoped (Effective) read falls back to the default, which is what the
# application itself resolves.
#
# An unregistered key reports NotSet at every scope, Effective included: it has
# no registered default, so "Default" there would name a fallback that does not
# exist and report $null as though the value had been resolved.
if($null -eq $value -and (-not $registered -or $TenantOnly -eq $true -or $GlobalOnly -eq $true))
{
$source = "NotSet"
}
elseif($null -eq $value)
{
$value = $definition.DefaultValue
}
# Same normalization as Get-SettingValue, so both agree on a stored "False".
if($definition.Type -eq "Boolean" -and $null -ne $value)
{
$value = $value -eq $true -or $value -eq "true"
}
[PSCustomObject]@{
Key = $definition.Key
Value = $value
Source = $source
Type = $definition.Type
SubPath = $definition.SubPath
Section = $definition.Section
Default = $definition.DefaultValue
TenantID = $TenantID
Title = $definition.Title
}
}
#endregion
#region Portable settings - a store as a file, independent of where it lives
# Write the whole current store to a JSON file, whatever store it is. This is the
# other half of the runbook story: check a settings file into source control, then
# load it into an in-memory store at the start of a run so the worker's own
# registry and settings file are neither read nor written.
function Export-SettingsStoreToFile
{
[CmdletBinding()]
param([string]$Path)
$tree = $script:JsonSettingsObj
if(-not $tree) { $tree = Get-PersistedSettingsTree }
if(-not $tree) { $tree = [PSCustomObject]@{} }
try
{
$fi = [IO.FileInfo]$Path
if($fi.Directory -and -not $fi.Directory.Exists) { $fi.Directory.Create() }
$tree | ConvertTo-Json -Depth 20 | Out-File -LiteralPath $Path -Force -Encoding utf8
$count = @(Get-SettingsTreeEntries $tree "").Count
Write-Log "Exported $count settings to $Path"
return $true
}
catch
{
Write-LogError "Failed to export settings to $Path" $_.Exception
return $false
}
}
# Load a settings file into the ACTIVE store, value by value through
# Save-SettingStoreValue rather than by swapping the tree. That is what makes one
# implementation correct for all three modes: the values persist in Json mode, land
# in the registry in Registry mode, and stay in memory in Memory mode - and an
# imported file merges into what is already there instead of replacing it.
#
# For a clean slate, switch to an unseeded memory store first
# (Use-IMSettingsStore -Memory) and import into that.
function Import-SettingsStoreFromFile
{
[CmdletBinding()]
param([string]$Path)
if(-not [IO.File]::Exists($Path))
{
Write-LogError "Settings file '$Path' does not exist"
return
}
try
{
$tree = ConvertFrom-Json ([IO.File]::ReadAllText($Path))
}
catch
{
Write-LogError "Failed to read settings file '$Path'" $_.Exception
return
}
$entries = @(Get-SettingsTreeEntries $tree "")
$unknown = @()
foreach($entry in $entries)
{
# A key with no registration is not an error - the hidden keys
# (ExportReplaceTokens) and the per-feature state namespaces are real and
# deliberately unregistered. A typo looks exactly the same though, and
# would silently do nothing, so say which ones they were.
if(-not (Get-SettingDefinitionByKey $entry.Key)) { $unknown += $entry.Key }
Save-SettingStoreValue $entry.SubPath $entry.Key $entry.Value "String"
}
if($unknown.Count -gt 0)
{
Write-Log "Imported $($unknown.Count) value(s) for keys that are not registered settings: $(($unknown | Sort-Object -Unique) -join ', ')" 2
}
Write-Log "Imported $($entries.Count) settings from $Path into the $((Get-SettingsStoreInfo).Mode) store"
return $entries.Count
}
#endregion