From 115b8be385063b2bbd462caa9040769096813997 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Thu, 2 Jul 2026 14:36:04 +0200 Subject: [PATCH] Add secret provider functionality and enhance configuration data resolution --- ...ssert-ConfigurationDataSecretReference.ps1 | 12 +-- .../ConvertTo-ConfigurationDataCredential.ps1 | 19 +++++ Private/Get-ConfigurationDataMapValue.ps1 | 9 +- .../Get-ConfigurationDataSecretProvider.ps1 | 19 +++++ ...gister-ConfigurationDataSecretProvider.ps1 | 40 +++++++++ ...olve-ConfigurationDataParameterSecrets.ps1 | 42 ++++++++++ ...solve-ConfigurationDataSecretReference.ps1 | 35 ++++++++ .../Test-ConfigurationDataSecretReference.ps1 | 14 ++++ Providers/Provider.KeePass.ps1 | 84 +++++++++++++++++++ Public/Resolve-DSCConfigurationData.ps1 | 14 +++- Readme.md | 59 ++++++++++++- Resolve-DSCConfigurationData.psm1 | 7 +- 12 files changed, 343 insertions(+), 11 deletions(-) create mode 100644 Private/ConvertTo-ConfigurationDataCredential.ps1 create mode 100644 Private/Get-ConfigurationDataSecretProvider.ps1 create mode 100644 Private/Register-ConfigurationDataSecretProvider.ps1 create mode 100644 Private/Resolve-ConfigurationDataParameterSecrets.ps1 create mode 100644 Private/Resolve-ConfigurationDataSecretReference.ps1 create mode 100644 Private/Test-ConfigurationDataSecretReference.ps1 create mode 100644 Providers/Provider.KeePass.ps1 diff --git a/Private/Assert-ConfigurationDataSecretReference.ps1 b/Private/Assert-ConfigurationDataSecretReference.ps1 index 96cc72b..a743da1 100644 --- a/Private/Assert-ConfigurationDataSecretReference.ps1 +++ b/Private/Assert-ConfigurationDataSecretReference.ps1 @@ -36,10 +36,12 @@ function Assert-ConfigurationDataSecretReference { throw "Parameter [$Name] secret reference [Name] must not be empty." } - switch($Provider.ToLowerInvariant()){ - "keepass" { return } - default { - throw "Parameter [$Name] uses unsupported secret provider [$Provider]." - } + $ProviderDefinition = Get-ConfigurationDataSecretProvider -Name $Provider + if($null -eq $ProviderDefinition){ + throw "Parameter [$Name] uses unsupported secret provider [$Provider]." + } + + if($ProviderDefinition.SupportedTypes -notcontains $TypeName.ToLowerInvariant()){ + throw "Parameter [$Name] uses provider [$Provider], but it does not support type [$TypeName]." } } diff --git a/Private/ConvertTo-ConfigurationDataCredential.ps1 b/Private/ConvertTo-ConfigurationDataCredential.ps1 new file mode 100644 index 0000000..2344e66 --- /dev/null +++ b/Private/ConvertTo-ConfigurationDataCredential.ps1 @@ -0,0 +1,19 @@ +function ConvertTo-ConfigurationDataCredential { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $UserName, + + [Parameter(Mandatory=$true)] + $Password + ) + + if($Password -is [System.Security.SecureString]){ + $SecurePassword = $Password + }else{ + $SecurePassword = ConvertTo-SecureString -String ([string]$Password) -AsPlainText -Force + } + + return [System.Management.Automation.PSCredential]::new($UserName, $SecurePassword) +} diff --git a/Private/Get-ConfigurationDataMapValue.ps1 b/Private/Get-ConfigurationDataMapValue.ps1 index 65a892a..5bbc23e 100644 --- a/Private/Get-ConfigurationDataMapValue.ps1 +++ b/Private/Get-ConfigurationDataMapValue.ps1 @@ -6,8 +6,15 @@ function Get-ConfigurationDataMapValue { [Parameter(Mandatory=$true)] [string] - $Key + $Key, + + [AllowNull()] + $DefaultValue = $null ) + if(-not (Test-ConfigurationDataMapContainsKey -Map $Map -Key $Key)){ + return $DefaultValue + } + return $Map[$Key] } diff --git a/Private/Get-ConfigurationDataSecretProvider.ps1 b/Private/Get-ConfigurationDataSecretProvider.ps1 new file mode 100644 index 0000000..7c1c8d4 --- /dev/null +++ b/Private/Get-ConfigurationDataSecretProvider.ps1 @@ -0,0 +1,19 @@ +function Get-ConfigurationDataSecretProvider { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $Name + ) + + if($null -eq $script:ConfigurationDataSecretProviders){ + $script:ConfigurationDataSecretProviders = @{} + } + + $ProviderName = $Name.ToLowerInvariant() + if(-not $script:ConfigurationDataSecretProviders.ContainsKey($ProviderName)){ + return $null + } + + return $script:ConfigurationDataSecretProviders[$ProviderName] +} diff --git a/Private/Register-ConfigurationDataSecretProvider.ps1 b/Private/Register-ConfigurationDataSecretProvider.ps1 new file mode 100644 index 0000000..521da04 --- /dev/null +++ b/Private/Register-ConfigurationDataSecretProvider.ps1 @@ -0,0 +1,40 @@ +function Register-ConfigurationDataSecretProvider { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $Name, + + [Parameter(Mandatory=$true)] + [string[]] + $SupportedTypes, + + [Parameter(Mandatory=$true)] + [scriptblock] + $Resolver + ) + + if([string]::IsNullOrWhiteSpace($Name)){ + throw "Secret provider [Name] must not be empty." + } + + if($SupportedTypes.Count -eq 0){ + throw "Secret provider [$Name] must define at least one supported type." + } + + foreach($Type in $SupportedTypes){ + if($Type -notin @("credential", "securestring", "string")){ + throw "Secret provider [$Name] uses unsupported type [$Type]." + } + } + + if($null -eq $script:ConfigurationDataSecretProviders){ + $script:ConfigurationDataSecretProviders = @{} + } + + $script:ConfigurationDataSecretProviders[$Name.ToLowerInvariant()] = [PSCustomObject]@{ + Name = $Name + SupportedTypes = @($SupportedTypes | ForEach-Object { $_.ToLowerInvariant() }) + Resolver = $Resolver + } +} diff --git a/Private/Resolve-ConfigurationDataParameterSecrets.ps1 b/Private/Resolve-ConfigurationDataParameterSecrets.ps1 new file mode 100644 index 0000000..4c4e2ee --- /dev/null +++ b/Private/Resolve-ConfigurationDataParameterSecrets.ps1 @@ -0,0 +1,42 @@ +function Resolve-ConfigurationDataParameterSecrets { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [System.Collections.Hashtable] + $ConfigurationData, + + [Parameter(Mandatory=$false)] + [hashtable] + $ProviderSettings = @{} + ) + + $ResolvedConfigurationData = $ConfigurationData.Clone() + + if($ResolvedConfigurationData.ContainsKey("Parameters")){ + $ResolvedConfigurationData.Parameters = $ResolvedConfigurationData.Parameters.Clone() + + foreach($Parameter in @($ResolvedConfigurationData.Parameters.GetEnumerator())){ + $Definition = $Parameter.Value + if(-not (Test-ConfigurationDataMap -Value $Definition)){ + continue + } + + $Definition = $Definition.Clone() + $ResolvedConfigurationData.Parameters[$Parameter.Name] = $Definition + + $TypeName = [string](Get-ConfigurationDataMapValue -Map $Definition -Key "Type" -DefaultValue "") + if($TypeName -notin @("credential", "secureString")){ + continue + } + + $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 + } + } + } + } + + return $ResolvedConfigurationData +} diff --git a/Private/Resolve-ConfigurationDataSecretReference.ps1 b/Private/Resolve-ConfigurationDataSecretReference.ps1 new file mode 100644 index 0000000..707428d --- /dev/null +++ b/Private/Resolve-ConfigurationDataSecretReference.ps1 @@ -0,0 +1,35 @@ +function Resolve-ConfigurationDataSecretReference { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [System.Collections.IDictionary] + $Reference, + + [Parameter(Mandatory=$true)] + [ValidateSet("credential", "securestring", "string")] + [string] + $ExpectedType, + + [Parameter(Mandatory=$false)] + [hashtable] + $ProviderSettings = @{} + ) + + $Provider = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "Provider") + $ProviderDefinition = Get-ConfigurationDataSecretProvider -Name $Provider + + if($null -eq $ProviderDefinition){ + throw "Unsupported secret provider [$Provider]." + } + + if($ProviderDefinition.SupportedTypes -notcontains $ExpectedType.ToLowerInvariant()){ + throw "Secret provider [$($ProviderDefinition.Name)] does not support type [$ExpectedType]." + } + + $CurrentProviderSettings = @{} + if($ProviderSettings.ContainsKey($ProviderDefinition.Name)){ + $CurrentProviderSettings = $ProviderSettings[$ProviderDefinition.Name] + } + + return & $ProviderDefinition.Resolver -Reference $Reference -ExpectedType $ExpectedType -ProviderSettings $CurrentProviderSettings +} diff --git a/Private/Test-ConfigurationDataSecretReference.ps1 b/Private/Test-ConfigurationDataSecretReference.ps1 new file mode 100644 index 0000000..29ee4b2 --- /dev/null +++ b/Private/Test-ConfigurationDataSecretReference.ps1 @@ -0,0 +1,14 @@ +function Test-ConfigurationDataSecretReference { + [CmdletBinding()] + Param( + [AllowNull()] + $Value + ) + + if(-not (Test-ConfigurationDataMap -Value $Value)){ + return $false + } + + return (Test-ConfigurationDataMapContainsKey -Map $Value -Key "Provider") -and + (Test-ConfigurationDataMapContainsKey -Map $Value -Key "Name") +} diff --git a/Providers/Provider.KeePass.ps1 b/Providers/Provider.KeePass.ps1 new file mode 100644 index 0000000..9510bfb --- /dev/null +++ b/Providers/Provider.KeePass.ps1 @@ -0,0 +1,84 @@ +$KeePassProvider = @{ + Name = "KeePass" + SupportedTypes = @( + "credential", + "securestring", + "string" + ) + Resolver = { + Param( + [Parameter(Mandatory=$true)] + [System.Collections.IDictionary] + $Reference, + + [Parameter(Mandatory=$true)] + [ValidateSet("credential", "securestring", "string")] + [string] + $ExpectedType, + + [Parameter(Mandatory=$false)] + [hashtable] + $ProviderSettings = @{} + ) + + $KeePassCommand = Get-Command -Name Get-KeePassEntry -ErrorAction SilentlyContinue + if($null -eq $KeePassCommand){ + throw "KeePass provider requires the [PoShKeePass] module command [Get-KeePassEntry]." + } + + $Vault = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "Vault" -DefaultValue "") + $Name = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "Name") + $Options = Get-ConfigurationDataMapValue -Map $Reference -Key "Options" -DefaultValue @{} + + if([string]::IsNullOrWhiteSpace($Vault) -and $ProviderSettings.ContainsKey("DefaultVault")){ + $Vault = [string]$ProviderSettings.DefaultVault + } + + $CommandParameters = @{} + if(-not [string]::IsNullOrWhiteSpace($Vault)){ + $CommandParameters["DatabaseProfileName"] = $Vault + } + + if($Options -is [System.Collections.IDictionary]){ + foreach($Key in $Options.Keys){ + $CommandParameters[$Key] = $Options[$Key] + } + } + + if(-not $CommandParameters.ContainsKey("Title") -and -not $CommandParameters.ContainsKey("Path")){ + $CommandParameters["Title"] = $Name + } + + $Entry = & $KeePassCommand @CommandParameters + if($null -eq $Entry){ + throw "KeePass entry [$Name] was not found." + } + + $Entry = @($Entry)[0] + $UserName = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "UserName" -DefaultValue "") + if([string]::IsNullOrWhiteSpace($UserName)){ + $UserName = [string]$Entry.UserName + } + + $Password = $Entry.Password + if($ExpectedType -eq "credential"){ + if([string]::IsNullOrWhiteSpace($UserName)){ + throw "KeePass entry [$Name] does not provide a username and no [UserName] override was defined." + } + + return ConvertTo-ConfigurationDataCredential -UserName $UserName -Password $Password + } + + if($Password -is [System.Security.SecureString]){ + return $Password + } + + if($ExpectedType -eq "securestring"){ + return ConvertTo-SecureString -String ([string]$Password) -AsPlainText -Force + } + + return [string]$Password + } +} + +Register-ConfigurationDataSecretProvider @KeePassProvider diff --git a/Public/Resolve-DSCConfigurationData.ps1 b/Public/Resolve-DSCConfigurationData.ps1 index 0d75c96..0f76bea 100644 --- a/Public/Resolve-DSCConfigurationData.ps1 +++ b/Public/Resolve-DSCConfigurationData.ps1 @@ -3,10 +3,22 @@ function Resolve-DSCConfigurationData { Param( [Parameter(Mandatory=$true, ValueFromPipeline=$true)] [System.Collections.Hashtable] - $ConfigurationData + $ConfigurationData, + + [Parameter(Mandatory=$false)] + [hashtable] + $ProviderSettings = @{}, + + [Parameter(Mandatory=$false)] + [switch] + $SkipSecrets ) process { + if(-not $SkipSecrets){ + $ConfigurationData = Resolve-ConfigurationDataParameterSecrets -ConfigurationData $ConfigurationData -ProviderSettings $ProviderSettings + } + $Context = New-ConfigurationDataResolutionContext -ConfigurationData $ConfigurationData return Resolve-ConfigurationDataValue -Value $ConfigurationData -Context $Context } diff --git a/Readme.md b/Readme.md index ed11d7f..15cd047 100644 --- a/Readme.md +++ b/Readme.md @@ -9,6 +9,13 @@ $merged = Merge-DSCConfigurationData -Template $service -Deployment $environment $resolved = Resolve-DSCConfigurationData -ConfigurationData $merged ``` +Pipeline flow: + +```powershell +$resolved = Merge-DSCConfigurationData -Template $service -Deployment $environment | + Resolve-DSCConfigurationData +``` + Expressions use an ARM-like syntax: ```powershell @@ -122,11 +129,57 @@ SetupCredential = @{ } ``` -Secret references are validated by `Resolve-DSCConfigurationData`, but they are resolved by `Resolve-DSCConfigurationSecrets` before the normal data resolve step: +Secret references are validated and resolved by `Resolve-DSCConfigurationData` before the normal parameter and variable resolve step: ```powershell -$withSecrets = Resolve-DSCConfigurationSecrets -ConfigurationData $merged -$resolved = Resolve-DSCConfigurationData -ConfigurationData $withSecrets +$resolved = Resolve-DSCConfigurationData -ConfigurationData $merged -ProviderSettings @{ + KeePass = @{ + DefaultVault = 'BGW' + } +} +``` + +Use `-SkipSecrets` when you only want structural validation/resolution without loading provider secrets. + +### Secret Provider Files + +Secret providers are loaded automatically from the module folder `Providers`. +The naming convention is: + +```text +Providers\Provider..ps1 +``` + +Each provider registers itself with the same schema: + +```powershell +$MyProvider = @{ + Name = 'MyProvider' + SupportedTypes = @( + 'credential', + 'securestring', + 'string' + ) + Resolver = { + param( + [System.Collections.IDictionary] $Reference, + [string] $ExpectedType, + [hashtable] $ProviderSettings + ) + + # Return a PSCredential for ExpectedType = credential, + # a SecureString for ExpectedType = securestring, + # or a string for ExpectedType = string. + } +} + +Register-ConfigurationDataSecretProvider @MyProvider +``` + +The KeePass provider is implemented in: + +```text +Providers\Provider.KeePass.ps1 ``` Array values can be restricted item by item: diff --git a/Resolve-DSCConfigurationData.psm1 b/Resolve-DSCConfigurationData.psm1 index 8c4eb93..ea80679 100644 --- a/Resolve-DSCConfigurationData.psm1 +++ b/Resolve-DSCConfigurationData.psm1 @@ -1,10 +1,15 @@ $PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath "Private" +$ProviderPath = Join-Path -Path $PSScriptRoot -ChildPath "Providers" $PublicPath = Join-Path -Path $PSScriptRoot -ChildPath "Public" $Private = @(Get-ChildItem -Path $PrivatePath -Filter "*.ps1" -File -ErrorAction Stop | Sort-Object -Property FullName) +$Providers = @() +if(Test-Path -Path $ProviderPath){ + $Providers = @(Get-ChildItem -Path $ProviderPath -Filter "Provider.*.ps1" -File -ErrorAction Stop | Sort-Object -Property FullName) +} $Public = @(Get-ChildItem -Path $PublicPath -Filter "*.ps1" -File -ErrorAction Stop | Sort-Object -Property FullName) -foreach($File in @($Private + $Public)){ +foreach($File in @($Private + $Providers + $Public)){ . $File.FullName }