From 4b67c74ac0637978ca122f7f0b1b80edd91d2b54 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 7 Jul 2026 21:50:50 +0200 Subject: [PATCH] Enhance configuration data handling with new reference resolution functions, dummy secret support, and update module version to 1.1.0 --- ...t-ConfigurationDataObjectPropertyValue.ps1 | 39 +++++++++++ Private/Get-ConfigurationDataPathValue.ps1 | 61 +++++++++++++++++ ...ke-ConfigurationDataExpressionFunction.ps1 | 11 +++ .../New-ConfigurationDataDummySecretValue.ps1 | 68 +++++++++++++++++++ ...New-ConfigurationDataResolutionContext.ps1 | 3 + ...olve-ConfigurationDataParameterSecrets.ps1 | 12 +++- .../Resolve-ConfigurationDataReference.ps1 | 46 +++++++++++++ Private/Resolve-ConfigurationDataValue.ps1 | 6 ++ Public/Resolve-DSCConfigurationData.ps1 | 4 +- Readme.md | 40 ++++++++++- Resolve-DSCConfigurationData.psd1 | 2 +- 11 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 Private/Get-ConfigurationDataObjectPropertyValue.ps1 create mode 100644 Private/Get-ConfigurationDataPathValue.ps1 create mode 100644 Private/New-ConfigurationDataDummySecretValue.ps1 create mode 100644 Private/Resolve-ConfigurationDataReference.ps1 diff --git a/Private/Get-ConfigurationDataObjectPropertyValue.ps1 b/Private/Get-ConfigurationDataObjectPropertyValue.ps1 new file mode 100644 index 0000000..936ca31 --- /dev/null +++ b/Private/Get-ConfigurationDataObjectPropertyValue.ps1 @@ -0,0 +1,39 @@ +function Get-ConfigurationDataObjectPropertyValue { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [AllowNull()] + $Value, + + [Parameter(Mandatory=$true)] + [string] + $Property, + + [Parameter(Mandatory=$false)] + [string] + $Path = "" + ) + + if([string]::IsNullOrWhiteSpace($Property)){ + throw "Configuration data reference property must not be empty." + } + + if($null -eq $Value){ + throw "Configuration data reference property [$Property] was requested from a null value at path [$Path]." + } + + if($Value -is [System.Collections.IDictionary]){ + if(-not $Value.Contains($Property)){ + throw "Configuration data reference property [$Property] was not found at path [$Path]." + } + + return $Value[$Property] + } + + $ObjectProperty = $Value.PSObject.Properties[$Property] + if($null -eq $ObjectProperty){ + throw "Configuration data reference property [$Property] was not found at path [$Path]." + } + + return $ObjectProperty.Value +} diff --git a/Private/Get-ConfigurationDataPathValue.ps1 b/Private/Get-ConfigurationDataPathValue.ps1 new file mode 100644 index 0000000..3e0e5e8 --- /dev/null +++ b/Private/Get-ConfigurationDataPathValue.ps1 @@ -0,0 +1,61 @@ +function Get-ConfigurationDataPathValue { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [AllowNull()] + $Value, + + [Parameter(Mandatory=$true)] + [string] + $Path + ) + + if([string]::IsNullOrWhiteSpace($Path)){ + throw "Configuration data reference path must not be empty." + } + + $Current = $Value + $Segments = @($Path -split '\.') + + foreach($Segment in $Segments){ + if([string]::IsNullOrWhiteSpace($Segment)){ + throw "Configuration data reference path [$Path] contains an empty segment." + } + + if($null -eq $Current){ + throw "Configuration data reference path [$Path] was not found. Segment [$Segment] resolved from a null value." + } + + if($Current -is [System.Collections.IDictionary]){ + if(-not $Current.Contains($Segment)){ + throw "Configuration data reference path [$Path] was not found. Segment [$Segment] is missing." + } + + $Current = $Current[$Segment] + continue + } + + if($Current -is [System.Array] -and $Current -isnot [string]){ + $Index = 0 + if(-not [int]::TryParse($Segment, [ref]$Index)){ + throw "Configuration data reference path [$Path] expected a numeric array index at segment [$Segment]." + } + + if($Index -lt 0 -or $Index -ge @($Current).Count){ + throw "Configuration data reference path [$Path] array index [$Index] is out of range." + } + + $Current = @($Current)[$Index] + continue + } + + $Property = $Current.PSObject.Properties[$Segment] + if($null -eq $Property){ + throw "Configuration data reference path [$Path] was not found. Property [$Segment] is missing." + } + + $Current = $Property.Value + } + + return $Current +} diff --git a/Private/Invoke-ConfigurationDataExpressionFunction.ps1 b/Private/Invoke-ConfigurationDataExpressionFunction.ps1 index 1eeef40..e729677 100644 --- a/Private/Invoke-ConfigurationDataExpressionFunction.ps1 +++ b/Private/Invoke-ConfigurationDataExpressionFunction.ps1 @@ -24,6 +24,17 @@ function Invoke-ConfigurationDataExpressionFunction { Assert-ConfigurationDataExpressionArgumentCount -Name $Name -Arguments $Arguments -Count 1 return Resolve-ConfigurationDataVariable -Name ([string]$Arguments[0]) -Context $Context } + "reference" { + if($Arguments.Count -eq 1){ + return Resolve-ConfigurationDataReference -Path ([string]$Arguments[0]) -Context $Context + } + + if($Arguments.Count -eq 2){ + return Resolve-ConfigurationDataReference -Path ([string]$Arguments[0]) -Property ([string]$Arguments[1]) -Context $Context + } + + throw "Function [$Name] expects 1 or 2 arguments, but received [$($Arguments.Count)]." + } "concat" { return (@($Arguments) | ForEach-Object { [string]$_ }) -join "" } diff --git a/Private/New-ConfigurationDataDummySecretValue.ps1 b/Private/New-ConfigurationDataDummySecretValue.ps1 new file mode 100644 index 0000000..d008718 --- /dev/null +++ b/Private/New-ConfigurationDataDummySecretValue.ps1 @@ -0,0 +1,68 @@ +function New-ConfigurationDataDummySecretValue { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [ValidateSet("credential", "securestring", "string")] + [string] + $ExpectedType, + + [Parameter(Mandatory=$false)] + [System.Collections.IDictionary] + $Reference, + + [Parameter(Mandatory=$false)] + [string] + $ParameterName = "" + ) + + $Name = "" + if($null -ne $Reference -and (Test-ConfigurationDataMapContainsKey -Map $Reference -Key "Name")){ + $Name = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "Name" -DefaultValue "") + } + + $UserName = "" + if($null -ne $Reference -and (Test-ConfigurationDataMapContainsKey -Map $Reference -Key "UserName")){ + $UserName = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "UserName" -DefaultValue "") + } + + if([string]::IsNullOrWhiteSpace($UserName)){ + $UserNameSource = $ParameterName + if([string]::IsNullOrWhiteSpace($UserNameSource)){ + $UserNameSource = $Name + } + + if([string]::IsNullOrWhiteSpace($UserNameSource)){ + $UserNameSource = "Credential" + } + + $UserNameLeaf = @($UserNameSource -split '[\\/]+')[-1] + if([string]::IsNullOrWhiteSpace($UserNameLeaf)){ + $UserNameLeaf = "Credential" + } + + $UserName = "DUMMY\$UserNameLeaf" + } + + switch($ExpectedType.ToLowerInvariant()){ + "credential" { + return [pscredential]::new( + $UserName, + (ConvertTo-SecureString -String "DummyPassword!" -AsPlainText -Force) + ) + } + "securestring" { + return ConvertTo-SecureString -String "DummySecret!" -AsPlainText -Force + } + "string" { + if([string]::IsNullOrWhiteSpace($Name)){ + $Name = $ParameterName + } + + if([string]::IsNullOrWhiteSpace($Name)){ + return "DummySecret" + } + + return "DummySecret:$Name" + } + } +} diff --git a/Private/New-ConfigurationDataResolutionContext.ps1 b/Private/New-ConfigurationDataResolutionContext.ps1 index 7257209..5c52bf2 100644 --- a/Private/New-ConfigurationDataResolutionContext.ps1 +++ b/Private/New-ConfigurationDataResolutionContext.ps1 @@ -7,10 +7,13 @@ function New-ConfigurationDataResolutionContext { ) $Context = [PSCustomObject]@{ + ConfigurationData = $ConfigurationData Parameters = @{} Variables = @{} VariableDefinitions = @{} ResolvingVariables = @{} + ReferenceCache = @{} + ResolvingReferences = @{} } if($ConfigurationData.ContainsKey("Parameters") -and $null -ne $ConfigurationData.Parameters){ diff --git a/Private/Resolve-ConfigurationDataParameterSecrets.ps1 b/Private/Resolve-ConfigurationDataParameterSecrets.ps1 index c5c420f..ff34e86 100644 --- a/Private/Resolve-ConfigurationDataParameterSecrets.ps1 +++ b/Private/Resolve-ConfigurationDataParameterSecrets.ps1 @@ -7,7 +7,11 @@ function Resolve-ConfigurationDataParameterSecrets { [Parameter(Mandatory=$false)] [hashtable] - $ProviderSettings = @{} + $ProviderSettings = @{}, + + [Parameter(Mandatory=$false)] + [switch] + $UseDummySecrets ) $ResolvedConfigurationData = $ConfigurationData.Clone() @@ -32,7 +36,11 @@ function Resolve-ConfigurationDataParameterSecrets { $ExpectedType = $TypeName.ToLowerInvariant() foreach($ValueKey in @("Value", "DefaultValue")){ if((Test-ConfigurationDataMapContainsKey -Map $Definition -Key $ValueKey) -and (Test-ConfigurationDataSecretReference -Value $Definition[$ValueKey])){ - $Definition[$ValueKey] = Resolve-ConfigurationDataSecretReference -Reference $Definition[$ValueKey] -ExpectedType $ExpectedType -ProviderSettings $ProviderSettings + if($UseDummySecrets){ + $Definition[$ValueKey] = New-ConfigurationDataDummySecretValue -ExpectedType $ExpectedType -Reference $Definition[$ValueKey] -ParameterName $Parameter.Name + }else{ + $Definition[$ValueKey] = Resolve-ConfigurationDataSecretReference -Reference $Definition[$ValueKey] -ExpectedType $ExpectedType -ProviderSettings $ProviderSettings + } } } } diff --git a/Private/Resolve-ConfigurationDataReference.ps1 b/Private/Resolve-ConfigurationDataReference.ps1 new file mode 100644 index 0000000..db52a61 --- /dev/null +++ b/Private/Resolve-ConfigurationDataReference.ps1 @@ -0,0 +1,46 @@ +function Resolve-ConfigurationDataReference { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $Path, + + [Parameter(Mandatory=$false)] + [AllowEmptyString()] + [string] + $Property = "", + + [Parameter(Mandatory=$true)] + $Context + ) + + $ReferenceKey = $Path + if(-not [string]::IsNullOrWhiteSpace($Property)){ + $ReferenceKey = "$Path::$Property" + } + + if($Context.ReferenceCache.ContainsKey($ReferenceKey)){ + return $Context.ReferenceCache[$ReferenceKey] + } + + if($Context.ResolvingReferences.ContainsKey($ReferenceKey)){ + $Stack = @($Context.ResolvingReferences.Keys) + $ReferenceKey + throw "Circular configuration data reference detected: $($Stack -join ' -> ')." + } + + $Context.ResolvingReferences[$ReferenceKey] = $true + try { + $RawValue = Get-ConfigurationDataPathValue -Value $Context.ConfigurationData -Path $Path + $ResolvedValue = Resolve-ConfigurationDataValue -Value $RawValue -Context $Context + + if(-not [string]::IsNullOrWhiteSpace($Property)){ + $ResolvedValue = Get-ConfigurationDataObjectPropertyValue -Value $ResolvedValue -Property $Property -Path $Path + } + + $Context.ReferenceCache[$ReferenceKey] = $ResolvedValue + return $ResolvedValue + } + finally { + $Context.ResolvingReferences.Remove($ReferenceKey) + } +} diff --git a/Private/Resolve-ConfigurationDataValue.ps1 b/Private/Resolve-ConfigurationDataValue.ps1 index cee5990..e1b38e9 100644 --- a/Private/Resolve-ConfigurationDataValue.ps1 +++ b/Private/Resolve-ConfigurationDataValue.ps1 @@ -15,6 +15,9 @@ function Resolve-ConfigurationDataValue { if($Value -is [System.Collections.Specialized.OrderedDictionary]){ $Resolved = [ordered]@{} foreach($Entry in $Value.GetEnumerator()){ + if($Entry.Name -eq "Sealed"){ + continue + } $Resolved[$Entry.Name] = Resolve-ConfigurationDataValue -Value $Entry.Value -Context $Context } return $Resolved @@ -23,6 +26,9 @@ function Resolve-ConfigurationDataValue { if($Value -is [System.Collections.Hashtable]){ $Resolved = @{} foreach($Entry in $Value.GetEnumerator()){ + if($Entry.Name -eq "Sealed"){ + continue + } $Resolved[$Entry.Name] = Resolve-ConfigurationDataValue -Value $Entry.Value -Context $Context } return $Resolved diff --git a/Public/Resolve-DSCConfigurationData.ps1 b/Public/Resolve-DSCConfigurationData.ps1 index 06b2000..d9670b5 100644 --- a/Public/Resolve-DSCConfigurationData.ps1 +++ b/Public/Resolve-DSCConfigurationData.ps1 @@ -27,7 +27,9 @@ function Resolve-DSCConfigurationData { $ProviderSettings = Import-PowerShellDataFile -Path $ProviderSettingsPath } - if(-not $SkipSecrets){ + if($SkipSecrets){ + $ConfigurationData = Resolve-ConfigurationDataParameterSecrets -ConfigurationData $ConfigurationData -ProviderSettings $ProviderSettings -UseDummySecrets + }else{ Unlock-ConfigurationDataSecretManagementVault -ProviderSettings $ProviderSettings $ConfigurationData = Resolve-ConfigurationDataParameterSecrets -ConfigurationData $ConfigurationData -ProviderSettings $ProviderSettings } diff --git a/Readme.md b/Readme.md index ddda271..9b79c57 100644 --- a/Readme.md +++ b/Readme.md @@ -64,6 +64,9 @@ This block shows all currently supported parameter properties. # Metadata for later reporting/output tooling. The resolver does not mask values yet. Sensitive = $false + # Prevents child templates from changing this parameter definition during merge. + Sealed = $false + # Emits a warning when the parameter is present. Deprecated = @{ Message = @{ @@ -89,6 +92,7 @@ Notes: - `Type`, `Required`, `AllowedValues`, `MinLength`, `MaxLength`, `MinValue`, `MaxValue`, and `Pattern` are validated by the resolver. - `AllowedValues` validates scalar values directly and array values item by item. - `Sensitive` is currently metadata only. +- `Sealed = $true` on a parameter seals the whole parameter definition during merge. Child templates cannot change any property of that parameter. - `Deprecated.Message` can be a string or a localized hashtable. - Optional string parameters can allow empty values with a pattern like `'^$|^[A-Za-z][A-Za-z0-9_-]*$'`. @@ -139,7 +143,11 @@ $resolved = Resolve-DSCConfigurationData -ConfigurationData $merged -ProviderSet } ``` -Use `-SkipSecrets` when you only want structural validation/resolution without loading provider secrets. +Use `-SkipSecrets` when you only want structural validation/resolution without loading provider secrets. Secret references are replaced with typed dummy values, so references such as `reference(..., 'UserName')` keep working in tests and previews. + +```powershell +$preview = Resolve-DSCConfigurationData -ConfigurationData $merged -SkipSecrets +``` SecretManagement is the only built-in secret resolver provider. KeePass, SecretStore, Azure Key Vault, and other backends should be registered as SecretManagement vaults. @@ -207,6 +215,30 @@ $resolved = Resolve-DSCConfigurationData ` -ProviderSettingsPath 'C:\DSC\Contoso\ProviderSettings.SecretManagement.psd1' ``` +## Sealed Template Blocks + +`Sealed = $true` can also be placed on any hashtable block in the configuration data. During merge, child templates cannot add or overwrite anything at that node or below it. + +```powershell +Resources = @{ + NonNodeData = @{ + Services = @{ + SharePoint = @{ + Farm = @{ + ManagedAccounts = @{ + Sealed = $true + + FarmAccount = "[parameters('FarmCredential')]" + } + } + } + } + } +} +``` + +The `Sealed` marker is kept during merge so later merge steps can enforce it. `Resolve-DSCConfigurationData` removes the marker from the final resolved data so DSC resource loops do not see it as a normal configuration item. + Inspect the configured provider and registered vault: ```powershell @@ -382,12 +414,16 @@ ConfigDbName : SharePoint_corp_TST_Farm_Config ```powershell "[parameters('DatabasePrefix')]" "[variables('ServiceDbPrefix')]" +"[reference('Resources.NonNodeData.Services.SharePoint.Farm.ManagedAccounts.FarmAccount')]" +"[reference('Resources.NonNodeData.Services.SharePoint.Farm.ManagedAccounts.FarmAccount', 'UserName')]" ``` `parameters(name)` returns the effective parameter value. `Value` is used before `DefaultValue`. `variables(name)` resolves another variable. Variables may reference other variables. +`reference(path)` resolves another value from the configuration data by path. `reference(path, property)` resolves the value and then returns a property from it, such as `UserName` from a `PSCredential`. + ### String Composition ```powershell @@ -590,6 +626,8 @@ ConfigDbName : SharePoint_corp_TST_Farm_Config - `parameters(name)` - `variables(name)` +- `reference(path)` +- `reference(path, property)` - `concat(value1, value2, ...)` - `format(formatString, value1, value2, ...)` - `coalesce(value1, value2, ...)` diff --git a/Resolve-DSCConfigurationData.psd1 b/Resolve-DSCConfigurationData.psd1 index 9af3fff..0f25225 100644 --- a/Resolve-DSCConfigurationData.psd1 +++ b/Resolve-DSCConfigurationData.psd1 @@ -1,6 +1,6 @@ @{ RootModule = "Resolve-DSCConfigurationData.psm1" - ModuleVersion = "1.0.2" + ModuleVersion = "1.1.0" GUID = "1d6ba0d4-93d5-4b0f-94c7-bf10a30fe0e8" Author = "Torsten Brendgen" Copyright = "(c) Torsten Brendgen. All rights reserved."