diff --git a/Private/Assert-ConfigurationDataParameterType.ps1 b/Private/Assert-ConfigurationDataParameterType.ps1 index d62cb53..5af9167 100644 --- a/Private/Assert-ConfigurationDataParameterType.ps1 +++ b/Private/Assert-ConfigurationDataParameterType.ps1 @@ -18,6 +18,10 @@ function Assert-ConfigurationDataParameterType { $TypeName = ([string](Get-ConfigurationDataMapValue -Map $Definition -Key "Type")).ToLowerInvariant() + if(Test-ConfigurationDataExpression -Value $Value){ + return + } + switch($TypeName){ "string" { if(Test-ConfigurationDataSecretReference -Value $Value){ diff --git a/Private/ConvertTo-ConfigurationDataDomainCredential.ps1 b/Private/ConvertTo-ConfigurationDataDomainCredential.ps1 new file mode 100644 index 0000000..afd2cef --- /dev/null +++ b/Private/ConvertTo-ConfigurationDataDomainCredential.ps1 @@ -0,0 +1,26 @@ +function ConvertTo-ConfigurationDataDomainCredential { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [System.Management.Automation.PSCredential] + $Credential, + + [Parameter(Mandatory=$true)] + [string] + $Domain + ) + + if([string]::IsNullOrWhiteSpace($Domain)){ + throw "Credential domain must not be empty." + } + + $UserName = [string]$Credential.UserName + if($UserName.Contains("\") -or $UserName.Contains("@")){ + return $Credential + } + + return [System.Management.Automation.PSCredential]::new( + "$Domain\$UserName", + $Credential.Password + ) +} diff --git a/Private/Invoke-ConfigurationDataExpressionFunction.ps1 b/Private/Invoke-ConfigurationDataExpressionFunction.ps1 index e729677..809e1d6 100644 --- a/Private/Invoke-ConfigurationDataExpressionFunction.ps1 +++ b/Private/Invoke-ConfigurationDataExpressionFunction.ps1 @@ -35,6 +35,71 @@ function Invoke-ConfigurationDataExpressionFunction { throw "Function [$Name] expects 1 or 2 arguments, but received [$($Arguments.Count)]." } + "parentkey" { + Assert-ConfigurationDataExpressionArgumentCount -Name $Name -Arguments $Arguments -Count 0 + + $Path = @($Context.CurrentPath) + if($Path.Count -lt 2){ + throw "Function [$Name] requires the expression to be nested inside a parent map." + } + + return [string]$Path[$Path.Count - 2] + } + "credentialwithdomain" { + Assert-ConfigurationDataExpressionArgumentCount -Name $Name -Arguments $Arguments -Count 2 + + if(-not ($Arguments[0] -is [System.Management.Automation.PSCredential])){ + throw "Function [$Name] expects a PSCredential as first argument." + } + + return ConvertTo-ConfigurationDataDomainCredential -Credential ([System.Management.Automation.PSCredential]$Arguments[0]) -Domain ([string]$Arguments[1]) + } + "firstnodenamewhere" { + if($Arguments.Count -ne 1 -and $Arguments.Count -ne 2){ + throw "Function [$Name] expects 1 or 2 arguments, but received [$($Arguments.Count)]." + } + + $PropertyName = [string]$Arguments[0] + if([string]::IsNullOrWhiteSpace($PropertyName)){ + throw "Function [$Name] requires a non-empty node property name." + } + + $ExpectedValue = $true + if($Arguments.Count -eq 2){ + $ExpectedValue = $Arguments[1] + } + + $AllNodes = $null + if($Context.ConfigurationData.ContainsKey("AllNodes")){ + $AllNodes = $Context.ConfigurationData.AllNodes + }elseif($Context.ConfigurationData.ContainsKey("Resources") -and + $Context.ConfigurationData.Resources -is [System.Collections.IDictionary] -and + $Context.ConfigurationData.Resources.ContainsKey("AllNodes")){ + $AllNodes = $Context.ConfigurationData.Resources.AllNodes + } + + foreach($Node in @($AllNodes)){ + if($null -eq $Node -or -not ($Node -is [System.Collections.IDictionary])){ + continue + } + + if(-not $Node.ContainsKey($PropertyName)){ + continue + } + + if($Node[$PropertyName] -ne $ExpectedValue){ + continue + } + + if(-not $Node.ContainsKey("NodeName") -or [string]::IsNullOrWhiteSpace([string]$Node.NodeName)){ + throw "Function [$Name] matched a node by [$PropertyName], but the node does not define [NodeName]." + } + + return [string]$Node.NodeName + } + + return $null + } "concat" { return (@($Arguments) | ForEach-Object { [string]$_ }) -join "" } diff --git a/Private/New-ConfigurationDataDummySecretValue.ps1 b/Private/New-ConfigurationDataDummySecretValue.ps1 index d008718..a76fe31 100644 --- a/Private/New-ConfigurationDataDummySecretValue.ps1 +++ b/Private/New-ConfigurationDataDummySecretValue.ps1 @@ -40,7 +40,11 @@ function New-ConfigurationDataDummySecretValue { $UserNameLeaf = "Credential" } - $UserName = "DUMMY\$UserNameLeaf" + if($null -ne $Reference -and (Test-ConfigurationDataMapContainsKey -Map $Reference -Key "Domain")){ + $UserName = $UserNameLeaf + }else{ + $UserName = "DUMMY\$UserNameLeaf" + } } switch($ExpectedType.ToLowerInvariant()){ diff --git a/Private/New-ConfigurationDataResolutionContext.ps1 b/Private/New-ConfigurationDataResolutionContext.ps1 index 5c52bf2..bad1a1c 100644 --- a/Private/New-ConfigurationDataResolutionContext.ps1 +++ b/Private/New-ConfigurationDataResolutionContext.ps1 @@ -14,6 +14,7 @@ function New-ConfigurationDataResolutionContext { ResolvingVariables = @{} ReferenceCache = @{} ResolvingReferences = @{} + CurrentPath = @() } if($ConfigurationData.ContainsKey("Parameters") -and $null -ne $ConfigurationData.Parameters){ diff --git a/Private/Resolve-ConfigurationDataParameterSecrets.ps1 b/Private/Resolve-ConfigurationDataParameterSecrets.ps1 index ff34e86..2b65712 100644 --- a/Private/Resolve-ConfigurationDataParameterSecrets.ps1 +++ b/Private/Resolve-ConfigurationDataParameterSecrets.ps1 @@ -15,6 +15,7 @@ function Resolve-ConfigurationDataParameterSecrets { ) $ResolvedConfigurationData = $ConfigurationData.Clone() + $Context = New-ConfigurationDataResolutionContext -ConfigurationData $ResolvedConfigurationData if($ResolvedConfigurationData.ContainsKey("Parameters")){ $ResolvedConfigurationData.Parameters = $ResolvedConfigurationData.Parameters.Clone() @@ -36,10 +37,19 @@ function Resolve-ConfigurationDataParameterSecrets { $ExpectedType = $TypeName.ToLowerInvariant() foreach($ValueKey in @("Value", "DefaultValue")){ if((Test-ConfigurationDataMapContainsKey -Map $Definition -Key $ValueKey) -and (Test-ConfigurationDataSecretReference -Value $Definition[$ValueKey])){ + $SecretReference = $Definition[$ValueKey] if($UseDummySecrets){ - $Definition[$ValueKey] = New-ConfigurationDataDummySecretValue -ExpectedType $ExpectedType -Reference $Definition[$ValueKey] -ParameterName $Parameter.Name + $Definition[$ValueKey] = New-ConfigurationDataDummySecretValue -ExpectedType $ExpectedType -Reference $SecretReference -ParameterName $Parameter.Name }else{ - $Definition[$ValueKey] = Resolve-ConfigurationDataSecretReference -Reference $Definition[$ValueKey] -ExpectedType $ExpectedType -ProviderSettings $ProviderSettings + $Definition[$ValueKey] = Resolve-ConfigurationDataSecretReference -Reference $SecretReference -ExpectedType $ExpectedType -ProviderSettings $ProviderSettings -Context $Context + } + + if($ExpectedType -eq "credential" -and + $UseDummySecrets -and + (Test-ConfigurationDataMapContainsKey -Map $SecretReference -Key "Domain")){ + $Domain = Get-ConfigurationDataMapValue -Map $SecretReference -Key "Domain" + $Domain = Resolve-ConfigurationDataValue -Value $Domain -Context $Context + $Definition[$ValueKey] = ConvertTo-ConfigurationDataDomainCredential -Credential $Definition[$ValueKey] -Domain ([string]$Domain) } } } diff --git a/Private/Resolve-ConfigurationDataSecretReference.ps1 b/Private/Resolve-ConfigurationDataSecretReference.ps1 index 707428d..ad456b0 100644 --- a/Private/Resolve-ConfigurationDataSecretReference.ps1 +++ b/Private/Resolve-ConfigurationDataSecretReference.ps1 @@ -12,7 +12,11 @@ function Resolve-ConfigurationDataSecretReference { [Parameter(Mandatory=$false)] [hashtable] - $ProviderSettings = @{} + $ProviderSettings = @{}, + + [Parameter(Mandatory=$false)] + [AllowNull()] + $Context = $null ) $Provider = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "Provider") @@ -31,5 +35,20 @@ function Resolve-ConfigurationDataSecretReference { $CurrentProviderSettings = $ProviderSettings[$ProviderDefinition.Name] } - return & $ProviderDefinition.Resolver -Reference $Reference -ExpectedType $ExpectedType -ProviderSettings $CurrentProviderSettings + $SecretValue = & $ProviderDefinition.Resolver -Reference $Reference -ExpectedType $ExpectedType -ProviderSettings $CurrentProviderSettings + + if($ExpectedType.ToLowerInvariant() -eq "credential" -and (Test-ConfigurationDataMapContainsKey -Map $Reference -Key "Domain")){ + if(-not ($SecretValue -is [System.Management.Automation.PSCredential])){ + throw "Secret reference [$($Reference.Name)] defines [Domain], but the resolved value is not a PSCredential." + } + + $Domain = Get-ConfigurationDataMapValue -Map $Reference -Key "Domain" + if($null -ne $Context){ + $Domain = Resolve-ConfigurationDataValue -Value $Domain -Context $Context + } + + $SecretValue = ConvertTo-ConfigurationDataDomainCredential -Credential $SecretValue -Domain ([string]$Domain) + } + + return $SecretValue } diff --git a/Private/Resolve-ConfigurationDataValue.ps1 b/Private/Resolve-ConfigurationDataValue.ps1 index e1b38e9..2ff7957 100644 --- a/Private/Resolve-ConfigurationDataValue.ps1 +++ b/Private/Resolve-ConfigurationDataValue.ps1 @@ -5,7 +5,10 @@ function Resolve-ConfigurationDataValue { $Value, [Parameter(Mandatory=$true)] - $Context + $Context, + + [object[]] + $Path = @() ) if($null -eq $Value){ @@ -18,7 +21,7 @@ function Resolve-ConfigurationDataValue { if($Entry.Name -eq "Sealed"){ continue } - $Resolved[$Entry.Name] = Resolve-ConfigurationDataValue -Value $Entry.Value -Context $Context + $Resolved[$Entry.Name] = Resolve-ConfigurationDataValue -Value $Entry.Value -Context $Context -Path (@($Path) + @($Entry.Name)) } return $Resolved } @@ -29,21 +32,27 @@ function Resolve-ConfigurationDataValue { if($Entry.Name -eq "Sealed"){ continue } - $Resolved[$Entry.Name] = Resolve-ConfigurationDataValue -Value $Entry.Value -Context $Context + $Resolved[$Entry.Name] = Resolve-ConfigurationDataValue -Value $Entry.Value -Context $Context -Path (@($Path) + @($Entry.Name)) } return $Resolved } if($Value -is [System.Array] -and $Value -isnot [string]){ $Resolved = @() - foreach($Item in $Value){ - $Resolved += ,(Resolve-ConfigurationDataValue -Value $Item -Context $Context) + for($Index = 0; $Index -lt $Value.Count; $Index++){ + $Resolved += ,(Resolve-ConfigurationDataValue -Value $Value[$Index] -Context $Context -Path (@($Path) + @($Index))) } return ,$Resolved } if($Value -is [string] -and (Test-ConfigurationDataExpression -Value $Value)){ - return Resolve-ConfigurationDataExpression -Expression $Value -Context $Context + $PreviousPath = @($Context.CurrentPath) + $Context.CurrentPath = @($Path) + try { + return Resolve-ConfigurationDataExpression -Expression $Value -Context $Context + } finally { + $Context.CurrentPath = $PreviousPath + } } return $Value diff --git a/Readme.md b/Readme.md index 9b79c57..8df66dd 100644 --- a/Readme.md +++ b/Readme.md @@ -424,6 +424,102 @@ ConfigDbName : SharePoint_corp_TST_Farm_Config `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`. +### Credentials + +`credentialWithDomain(credential, domain)` returns a new `PSCredential` with the same password and a domain-qualified user name. If the user name already contains `DOMAIN\User` or `user@domain`, the original credential is returned. + +This is useful when a secret backend such as KeePass stores the user name without a domain, but the DSC resource expects a domain-qualified credential: + +```powershell +Parameters = @{ + DomainNetBIOS = @{ + Type = 'string' + Value = 'CONTOSO' + } + + FarmCredential = @{ + Type = 'credential' + Value = @{ + Provider = 'SecretManagement' + Vault = 'KeePass' + Name = 'SharePoint/FarmAccount' + Domain = "[parameters('DomainNetBIOS')]" + } + } +} +``` + +The same transformation can also be used explicitly in expressions: + +```powershell +FarmCredentialQualified = @{ + Type = 'credential' + DefaultValue = "[credentialWithDomain(parameters('FarmCredential'), parameters('DomainNetBIOS'))]" +} +``` + +### Node Lookup + +`firstNodeNameWhere(propertyName)` returns the first `NodeName` from `AllNodes` where the named property is `$true`. +`firstNodeNameWhere(propertyName, expectedValue)` compares the property with the provided value. + +```powershell +AllNodes = @( + @{ + NodeName = 'SP01' + RunCentralAdmin = $true + } + @{ + NodeName = 'SP02' + RunCentralAdmin = $false + } +) +``` + +```powershell +CentralAdministrationHostName = @{ + Type = 'string' + DefaultValue = "[firstNodeNameWhere('RunCentralAdmin')]" +} + +CentralAdministrationUrl = @{ + Type = 'string' + DefaultValue = "[format('https://{0}:{1}', parameters('CentralAdministrationHostName'), parameters('CentralAdministrationPort'))]" +} +``` + +If a fixed URL is required, set an explicit parameter value and the default expression is not used. + +### Parent Map Key + +`parentKey()` returns the key of the map that contains the current expression property. It can be used to derive resource-specific parameter names while retaining a shared default: + +```powershell +Parameters = @{ + DefaultWebApplicationAuthenticationMethod = @{ + Type = 'string' + DefaultValue = 'NTLM' + } + HNSCWebApplicationAuthenticationMethod = @{ + Type = 'string' + Value = 'Kerberos' + } +} + +Resources = @{ + WebApplications = @{ + HNSC = @{ + AuthenticationMethod = "[coalesce(parameters(concat(parentKey(), 'WebApplicationAuthenticationMethod')), parameters('DefaultWebApplicationAuthenticationMethod'))]" + } + MySite = @{ + AuthenticationMethod = "[coalesce(parameters(concat(parentKey(), 'WebApplicationAuthenticationMethod')), parameters('DefaultWebApplicationAuthenticationMethod'))]" + } + } +} +``` + +The `HNSC` resource resolves the optional `HNSCWebApplicationAuthenticationMethod` override. `MySite` falls back to `DefaultWebApplicationAuthenticationMethod` when no corresponding override parameter exists. + ### String Composition ```powershell @@ -628,6 +724,10 @@ ConfigDbName : SharePoint_corp_TST_Farm_Config - `variables(name)` - `reference(path)` - `reference(path, property)` +- `credentialWithDomain(credential, domain)` +- `firstNodeNameWhere(propertyName)` +- `firstNodeNameWhere(propertyName, expectedValue)` +- `parentKey()` - `concat(value1, value2, ...)` - `format(formatString, value1, value2, ...)` - `coalesce(value1, value2, ...)` diff --git a/Resolve-DSCConfigurationData.psd1 b/Resolve-DSCConfigurationData.psd1 index 0f25225..6a3b71e 100644 --- a/Resolve-DSCConfigurationData.psd1 +++ b/Resolve-DSCConfigurationData.psd1 @@ -1,6 +1,6 @@ @{ RootModule = "Resolve-DSCConfigurationData.psm1" - ModuleVersion = "1.1.0" + ModuleVersion = "1.2.0" GUID = "1d6ba0d4-93d5-4b0f-94c7-bf10a30fe0e8" Author = "Torsten Brendgen" Copyright = "(c) Torsten Brendgen. All rights reserved." @@ -22,7 +22,7 @@ "DSC", "ConfigurationData" ) - ReleaseNotes = "Initial module layout." + ReleaseNotes = "Adds parentKey() for context-aware parameter overrides, domain-qualified credential secret references, and node lookup expression functions." } } }