diff --git a/Private/ConvertFrom-ConfigurationDataSecretValue.ps1 b/Private/ConvertFrom-ConfigurationDataSecretValue.ps1 new file mode 100644 index 0000000..6e0f814 --- /dev/null +++ b/Private/ConvertFrom-ConfigurationDataSecretValue.ps1 @@ -0,0 +1,101 @@ +function ConvertFrom-ConfigurationDataSecretValue { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [AllowNull()] + $Secret, + + [Parameter(Mandatory=$true)] + [ValidateSet("credential", "securestring", "string")] + [string] + $ExpectedType, + + [Parameter(Mandatory=$false)] + [string] + $Name = "", + + [Parameter(Mandatory=$false)] + [string] + $UserName = "" + ) + + if($null -eq $Secret){ + throw "Secret [$Name] was not found." + } + + if($ExpectedType -eq "credential"){ + if($Secret -is [System.Management.Automation.PSCredential]){ + return $Secret + } + + if($Secret -is [System.Collections.IDictionary]){ + if([string]::IsNullOrWhiteSpace($UserName) -and $Secret.Contains("UserName")){ + $UserName = [string]$Secret["UserName"] + } + + if($Secret.Contains("Password")){ + return ConvertTo-ConfigurationDataCredential -UserName $UserName -Password $Secret["Password"] + } + } + + if($Secret.PSObject.Properties["UserName"] -and [string]::IsNullOrWhiteSpace($UserName)){ + $UserName = [string]$Secret.UserName + } + + if($Secret.PSObject.Properties["Password"]){ + return ConvertTo-ConfigurationDataCredential -UserName $UserName -Password $Secret.Password + } + + if([string]::IsNullOrWhiteSpace($UserName)){ + throw "Secret [$Name] cannot be converted to [PSCredential] because no username was provided." + } + + return ConvertTo-ConfigurationDataCredential -UserName $UserName -Password $Secret + } + + if($ExpectedType -eq "securestring"){ + if($Secret -is [System.Security.SecureString]){ + return $Secret + } + + if($Secret -is [System.Management.Automation.PSCredential]){ + return $Secret.Password + } + + if($Secret -is [System.Collections.IDictionary] -and $Secret.Contains("Password")){ + $Secret = $Secret["Password"] + }elseif($Secret.PSObject.Properties["Password"]){ + $Secret = $Secret.Password + } + + if($Secret -is [System.Security.SecureString]){ + return $Secret + } + + return ConvertTo-SecureString -String ([string]$Secret) -AsPlainText -Force + } + + if($Secret -is [System.Management.Automation.PSCredential]){ + return $Secret.GetNetworkCredential().Password + } + + if($Secret -is [System.Security.SecureString]){ + $Pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secret) + try { + return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($Pointer) + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Pointer) + } + } + + if($Secret -is [System.Collections.IDictionary] -and $Secret.Contains("Password")){ + return [string]$Secret["Password"] + } + + if($Secret.PSObject.Properties["Password"]){ + return [string]$Secret.Password + } + + return [string]$Secret +} diff --git a/Private/ConvertTo-PowerShellDataFileText.ps1 b/Private/ConvertTo-PowerShellDataFileText.ps1 new file mode 100644 index 0000000..7c3142e --- /dev/null +++ b/Private/ConvertTo-PowerShellDataFileText.ps1 @@ -0,0 +1,63 @@ +function ConvertTo-PowerShellDataFileText { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [AllowNull()] + $InputObject, + + [Parameter(Mandatory=$false)] + [int] + $Indent = 0 + ) + + $IndentText = " " * $Indent + $ChildIndent = $Indent + 4 + $ChildIndentText = " " * $ChildIndent + + if($null -eq $InputObject){ + return '$null' + } + + if($InputObject -is [bool]){ + if($InputObject){ + return '$true' + } + + return '$false' + } + + if($InputObject -is [int] -or $InputObject -is [long] -or $InputObject -is [decimal] -or $InputObject -is [double]){ + return ([string]$InputObject) + } + + if($InputObject -is [string]){ + return "'$($InputObject.Replace("'", "''"))'" + } + + if($InputObject -is [System.Collections.IDictionary]){ + $Lines = @("@{") + foreach($Key in $InputObject.Keys){ + $ValueText = ConvertTo-PowerShellDataFileText -InputObject $InputObject[$Key] -Indent $ChildIndent + $Lines += "$ChildIndentText$Key = $ValueText" + } + $Lines += "$IndentText}" + return ($Lines -join [Environment]::NewLine) + } + + if($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]){ + $Items = @($InputObject) + if($Items.Count -eq 0){ + return '@()' + } + + $Lines = @("@(") + foreach($Item in $Items){ + $ValueText = ConvertTo-PowerShellDataFileText -InputObject $Item -Indent $ChildIndent + $Lines += "$ChildIndentText$ValueText" + } + $Lines += "$IndentText)" + return ($Lines -join [Environment]::NewLine) + } + + return "'$(([string]$InputObject).Replace("'", "''"))'" +} diff --git a/Private/Export-PowerShellDataFile.ps1 b/Private/Export-PowerShellDataFile.ps1 new file mode 100644 index 0000000..24cc4fb --- /dev/null +++ b/Private/Export-PowerShellDataFile.ps1 @@ -0,0 +1,28 @@ +function Export-PowerShellDataFile { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [System.Collections.IDictionary] + $InputObject, + + [Parameter(Mandatory=$true)] + [string] + $Path, + + [Parameter(Mandatory=$false)] + [switch] + $Force + ) + + if((Test-Path -Path $Path -PathType Leaf) -and (-not $Force)){ + throw "File [$Path] already exists. Use -Force to overwrite it." + } + + $Parent = Split-Path -Path $Path -Parent + if(-not [string]::IsNullOrWhiteSpace($Parent) -and -not (Test-Path -Path $Parent)){ + New-Item -Path $Parent -ItemType Directory -Force | Out-Null + } + + $Text = ConvertTo-PowerShellDataFileText -InputObject $InputObject + Set-Content -Path $Path -Value $Text -Encoding UTF8 +} diff --git a/Private/Get-ConfigurationDataSecretProviderName.ps1 b/Private/Get-ConfigurationDataSecretProviderName.ps1 new file mode 100644 index 0000000..3b067d0 --- /dev/null +++ b/Private/Get-ConfigurationDataSecretProviderName.ps1 @@ -0,0 +1,14 @@ +function Get-ConfigurationDataSecretProviderName { + [CmdletBinding()] + Param() + + if($null -eq $script:ConfigurationDataSecretProviders){ + $script:ConfigurationDataSecretProviders = @{} + } + + return @( + $script:ConfigurationDataSecretProviders.Values | + Sort-Object -Property Name | + ForEach-Object { $_.Name } + ) +} diff --git a/Private/New-ConfigurationDataAesKeyFile.ps1 b/Private/New-ConfigurationDataAesKeyFile.ps1 new file mode 100644 index 0000000..89c8e90 --- /dev/null +++ b/Private/New-ConfigurationDataAesKeyFile.ps1 @@ -0,0 +1,31 @@ +function New-ConfigurationDataAesKeyFile { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $Path, + + [Parameter(Mandatory=$false)] + [switch] + $Force + ) + + if((Test-Path -Path $Path -PathType Leaf) -and (-not $Force)){ + $Key = [Convert]::FromBase64String((Get-Content -Path $Path -Raw).Trim()) + if($Key.Length -notin @(16, 24, 32)){ + throw "Existing key file [$Path] does not contain a valid AES key length." + } + + return $Key + } + + $Parent = Split-Path -Path $Path -Parent + if(-not [string]::IsNullOrWhiteSpace($Parent) -and -not (Test-Path -Path $Parent)){ + New-Item -Path $Parent -ItemType Directory -Force | Out-Null + } + + $Key = New-Object byte[] 32 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($Key) + Set-Content -Path $Path -Value ([Convert]::ToBase64String($Key)) -Encoding ASCII + return $Key +} diff --git a/Private/Resolve-ConfigurationDataProviderSecureString.ps1 b/Private/Resolve-ConfigurationDataProviderSecureString.ps1 new file mode 100644 index 0000000..a56d1af --- /dev/null +++ b/Private/Resolve-ConfigurationDataProviderSecureString.ps1 @@ -0,0 +1,51 @@ +function Resolve-ConfigurationDataProviderSecureString { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + $Value + ) + + if($Value -is [System.Security.SecureString]){ + return $Value + } + + if($Value -is [string]){ + return ConvertTo-SecureString -String $Value -AsPlainText -Force + } + + if(-not (Test-ConfigurationDataMap -Value $Value)){ + throw "Provider secure string value must be a SecureString, string, or hashtable." + } + + if(Test-ConfigurationDataMapContainsKey -Map $Value -Key "EnvironmentVariable"){ + $VariableName = [string](Get-ConfigurationDataMapValue -Map $Value -Key "EnvironmentVariable") + $EnvironmentValue = [Environment]::GetEnvironmentVariable($VariableName) + if([string]::IsNullOrEmpty($EnvironmentValue)){ + throw "Environment variable [$VariableName] is not defined or empty." + } + + return ConvertTo-SecureString -String $EnvironmentValue -AsPlainText -Force + } + + if(Test-ConfigurationDataMapContainsKey -Map $Value -Key "ProtectedValue"){ + $ProtectedValue = [string](Get-ConfigurationDataMapValue -Map $Value -Key "ProtectedValue") + $Key = $null + + if(Test-ConfigurationDataMapContainsKey -Map $Value -Key "Key"){ + $Key = [Convert]::FromBase64String([string](Get-ConfigurationDataMapValue -Map $Value -Key "Key")) + }elseif(Test-ConfigurationDataMapContainsKey -Map $Value -Key "KeyPath"){ + $KeyPath = [string](Get-ConfigurationDataMapValue -Map $Value -Key "KeyPath") + if(-not (Test-Path -Path $KeyPath -PathType Leaf)){ + throw "Secure string key file [$KeyPath] was not found." + } + + $Key = [Convert]::FromBase64String((Get-Content -Path $KeyPath -Raw).Trim()) + }else{ + throw "Protected provider secure string requires [Key] or [KeyPath]." + } + + return ConvertTo-SecureString -String $ProtectedValue -Key $Key + } + + throw "Unsupported provider secure string reference." +} diff --git a/Private/Resolve-ConfigurationDataProviderSettingsPath.ps1 b/Private/Resolve-ConfigurationDataProviderSettingsPath.ps1 new file mode 100644 index 0000000..288e073 --- /dev/null +++ b/Private/Resolve-ConfigurationDataProviderSettingsPath.ps1 @@ -0,0 +1,25 @@ +function Resolve-ConfigurationDataProviderSettingsPath { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + [string] + $Provider, + + [Parameter(Mandatory=$false)] + [AllowEmptyString()] + [string] + $SettingsPath + ) + + $FileName = "ProviderSettings.$Provider.psd1" + + if([string]::IsNullOrWhiteSpace($SettingsPath)){ + return (Join-Path -Path (Get-Location).Path -ChildPath $FileName) + } + + if((Test-Path -Path $SettingsPath -PathType Container) -or $SettingsPath.EndsWith("\") -or $SettingsPath.EndsWith("/")){ + return (Join-Path -Path $SettingsPath -ChildPath $FileName) + } + + return $SettingsPath +} diff --git a/Providers/Provider.KeePass.ps1 b/Providers/Provider.KeePass.ps1 index 9510bfb..4ebdf94 100644 --- a/Providers/Provider.KeePass.ps1 +++ b/Providers/Provider.KeePass.ps1 @@ -45,6 +45,10 @@ $KeePassProvider = @{ } } + if($ProviderSettings.ContainsKey("MasterKey")){ + $CommandParameters["MasterKey"] = Resolve-ConfigurationDataProviderSecureString -Value $ProviderSettings.MasterKey + } + if(-not $CommandParameters.ContainsKey("Title") -and -not $CommandParameters.ContainsKey("Path")){ $CommandParameters["Title"] = $Name } diff --git a/Providers/Provider.SecretManagement.ps1 b/Providers/Provider.SecretManagement.ps1 new file mode 100644 index 0000000..27bcb31 --- /dev/null +++ b/Providers/Provider.SecretManagement.ps1 @@ -0,0 +1,57 @@ +$SecretManagementProvider = @{ + Name = "SecretManagement" + 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 = @{} + ) + + $GetSecretCommand = Get-Command -Name Get-Secret -ErrorAction SilentlyContinue + if($null -eq $GetSecretCommand){ + throw "SecretManagement provider requires the module [Microsoft.PowerShell.SecretManagement] and command [Get-Secret]." + } + + $Vault = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "Vault" -DefaultValue "") + $Name = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "Name") + $UserName = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "UserName" -DefaultValue "") + $Options = Get-ConfigurationDataMapValue -Map $Reference -Key "Options" -DefaultValue @{} + + if([string]::IsNullOrWhiteSpace($Vault) -and $ProviderSettings.ContainsKey("DefaultVault")){ + $Vault = [string]$ProviderSettings.DefaultVault + } + + $CommandParameters = @{ + Name = $Name + } + + if(-not [string]::IsNullOrWhiteSpace($Vault)){ + $CommandParameters["Vault"] = $Vault + } + + if($Options -is [System.Collections.IDictionary]){ + foreach($Key in $Options.Keys){ + $CommandParameters[$Key] = $Options[$Key] + } + } + + $Secret = & $GetSecretCommand @CommandParameters + return ConvertFrom-ConfigurationDataSecretValue -Secret $Secret -ExpectedType $ExpectedType -Name $Name -UserName $UserName + } +} + +Register-ConfigurationDataSecretProvider @SecretManagementProvider diff --git a/Providers/Provider.SecretStore.ps1 b/Providers/Provider.SecretStore.ps1 new file mode 100644 index 0000000..e94fa94 --- /dev/null +++ b/Providers/Provider.SecretStore.ps1 @@ -0,0 +1,57 @@ +$SecretStoreProvider = @{ + Name = "SecretStore" + 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 = @{} + ) + + $GetSecretCommand = Get-Command -Name Get-Secret -ErrorAction SilentlyContinue + if($null -eq $GetSecretCommand){ + throw "SecretStore provider requires the module [Microsoft.PowerShell.SecretManagement] and command [Get-Secret]." + } + + $Vault = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "Vault" -DefaultValue "") + $Name = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "Name") + $UserName = [string](Get-ConfigurationDataMapValue -Map $Reference -Key "UserName" -DefaultValue "") + $Options = Get-ConfigurationDataMapValue -Map $Reference -Key "Options" -DefaultValue @{} + + if([string]::IsNullOrWhiteSpace($Vault) -and $ProviderSettings.ContainsKey("DefaultVault")){ + $Vault = [string]$ProviderSettings.DefaultVault + } + + $CommandParameters = @{ + Name = $Name + } + + if(-not [string]::IsNullOrWhiteSpace($Vault)){ + $CommandParameters["Vault"] = $Vault + } + + if($Options -is [System.Collections.IDictionary]){ + foreach($Key in $Options.Keys){ + $CommandParameters[$Key] = $Options[$Key] + } + } + + $Secret = & $GetSecretCommand @CommandParameters + return ConvertFrom-ConfigurationDataSecretValue -Secret $Secret -ExpectedType $ExpectedType -Name $Name -UserName $UserName + } +} + +Register-ConfigurationDataSecretProvider @SecretStoreProvider diff --git a/Public/Register-DSCConfigurationDataCredentialProvider.ps1 b/Public/Register-DSCConfigurationDataCredentialProvider.ps1 new file mode 100644 index 0000000..d85493f --- /dev/null +++ b/Public/Register-DSCConfigurationDataCredentialProvider.ps1 @@ -0,0 +1,247 @@ +function Register-DSCConfigurationDataCredentialProvider { + [CmdletBinding(SupportsShouldProcess=$true)] + Param( + [Parameter(Mandatory=$true)] + [string] + $Vault, + + [Parameter(Mandatory=$false)] + [string] + $KeyPath, + + [Parameter(Mandatory=$false)] + [string] + $SettingsPath, + + [Parameter(Mandatory=$false)] + [System.Security.SecureString] + $MasterKey, + + [Parameter(Mandatory=$false)] + [string] + $ModuleName, + + [Parameter(Mandatory=$false)] + [hashtable] + $VaultParameters = @{}, + + [Parameter(Mandatory=$false)] + [switch] + $RegisterVault, + + [Parameter(Mandatory=$false)] + [switch] + $DefaultVault, + + [Parameter(Mandatory=$false)] + [switch] + $AllowClobber, + + [Parameter(Mandatory=$false)] + [switch] + $ConfigureSecretStore, + + [Parameter(Mandatory=$false)] + [ValidateSet("None", "Password")] + [string] + $Authentication = "None", + + [Parameter(Mandatory=$false)] + [ValidateSet("None", "Prompt")] + [string] + $Interaction = "None", + + [Parameter(Mandatory=$false)] + [System.Security.SecureString] + $Password, + + [Parameter(Mandatory=$false)] + [int] + $PasswordTimeout, + + [Parameter(Mandatory=$false)] + [switch] + $Force, + + [Parameter(Mandatory=$false)] + [switch] + $PassThru + ) + + DynamicParam { + $ProviderNames = @(Get-ConfigurationDataSecretProviderName) + if($ProviderNames.Count -eq 0){ + $ProviderNames = @("__NoProvidersRegistered__") + } + + $ParameterAttribute = [System.Management.Automation.ParameterAttribute]::new() + $ParameterAttribute.Mandatory = $false + $ParameterAttribute.Position = 0 + + $ValidateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute -ArgumentList (,[string[]]$ProviderNames) + $Attributes = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() + $Attributes.Add($ParameterAttribute) + $Attributes.Add($ValidateSetAttribute) + + $RuntimeParameter = [System.Management.Automation.RuntimeDefinedParameter]::new( + "Provider", + [string], + $Attributes + ) + + $Dictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new() + $Dictionary.Add("Provider", $RuntimeParameter) + return $Dictionary + } + + begin { + $Provider = [string]$PSBoundParameters["Provider"] + if([string]::IsNullOrWhiteSpace($Provider)){ + $ProviderNames = @(Get-ConfigurationDataSecretProviderName) + if($ProviderNames.Count -eq 1){ + $Provider = $ProviderNames[0] + }elseif($ProviderNames.Count -eq 0){ + throw "No configuration data secret providers are registered." + }else{ + throw "Provider is required. Registered providers: $($ProviderNames -join ', ')." + } + } + + $SettingsPath = Resolve-ConfigurationDataProviderSettingsPath -Provider $Provider -SettingsPath $SettingsPath + } + + process { + switch($Provider){ + "KeePass" { + if([string]::IsNullOrWhiteSpace($KeyPath)){ + throw "Provider [KeePass] requires [KeyPath]." + } + + if($null -eq $MasterKey){ + $MasterKey = Read-Host -Prompt "KeePass MasterKey for vault [$Vault]" -AsSecureString + } + + if($PSCmdlet.ShouldProcess($SettingsPath, "Create provider settings for [$Provider]")){ + $Key = New-ConfigurationDataAesKeyFile -Path $KeyPath -Force:$Force + $ProtectedValue = $MasterKey | ConvertFrom-SecureString -Key $Key + + $ProviderSettings = [ordered]@{ + KeePass = [ordered]@{ + DefaultVault = $Vault + MasterKey = [ordered]@{ + ProtectedValue = $ProtectedValue + KeyPath = $KeyPath + } + } + } + + Export-PowerShellDataFile -InputObject $ProviderSettings -Path $SettingsPath -Force:$Force + + if($PassThru){ + return $ProviderSettings + } + + Write-Verbose "Provider settings written to [$SettingsPath]." + } + } + "SecretManagement" { + if([string]::IsNullOrWhiteSpace($ModuleName)){ + $ModuleName = "Microsoft.PowerShell.SecretStore" + } + + if($RegisterVault -or $PSBoundParameters.ContainsKey("ModuleName")){ + $CommandParameters = @{ + Name = $Vault + ModuleName = $ModuleName + } + + if($VaultParameters.Count -gt 0){ + $CommandParameters["VaultParameters"] = $VaultParameters + } + + if($DefaultVault){ + $CommandParameters["DefaultVault"] = $true + } + + if($AllowClobber){ + $CommandParameters["AllowClobber"] = $true + } + + if($PSCmdlet.ShouldProcess($Vault, "Register SecretManagement vault [$ModuleName]")){ + Register-SecretVault @CommandParameters + } + } + + $ProviderSettings = [ordered]@{ + SecretManagement = [ordered]@{ + DefaultVault = $Vault + } + } + + if($PSCmdlet.ShouldProcess($SettingsPath, "Create provider settings for [$Provider]")){ + Export-PowerShellDataFile -InputObject $ProviderSettings -Path $SettingsPath -Force:$Force + } + + if($PassThru){ + return $ProviderSettings + } + } + "SecretStore" { + if($ConfigureSecretStore){ + $CommandParameters = @{ + Authentication = $Authentication + Interaction = $Interaction + } + + if($null -ne $Password){ + $CommandParameters["Password"] = $Password + } + + if($PasswordTimeout -gt 0){ + $CommandParameters["PasswordTimeout"] = $PasswordTimeout + } + + if($PSCmdlet.ShouldProcess("SecretStore", "Configure local SecretStore")){ + Set-SecretStoreConfiguration @CommandParameters -Confirm:$false + } + } + + if($RegisterVault){ + $CommandParameters = @{ + Name = $Vault + ModuleName = "Microsoft.PowerShell.SecretStore" + } + + if($DefaultVault){ + $CommandParameters["DefaultVault"] = $true + } + + if($AllowClobber){ + $CommandParameters["AllowClobber"] = $true + } + + if($PSCmdlet.ShouldProcess($Vault, "Register SecretStore vault")){ + Register-SecretVault @CommandParameters + } + } + + $ProviderSettings = [ordered]@{ + SecretStore = [ordered]@{ + DefaultVault = $Vault + } + } + + if($PSCmdlet.ShouldProcess($SettingsPath, "Create provider settings for [$Provider]")){ + Export-PowerShellDataFile -InputObject $ProviderSettings -Path $SettingsPath -Force:$Force + } + + if($PassThru){ + return $ProviderSettings + } + } + default { + throw "Provider [$Provider] does not provide an initialization implementation." + } + } + } +} diff --git a/Public/Resolve-DSCConfigurationData.ps1 b/Public/Resolve-DSCConfigurationData.ps1 index 0f76bea..7363775 100644 --- a/Public/Resolve-DSCConfigurationData.ps1 +++ b/Public/Resolve-DSCConfigurationData.ps1 @@ -9,12 +9,24 @@ function Resolve-DSCConfigurationData { [hashtable] $ProviderSettings = @{}, + [Parameter(Mandatory=$false)] + [string] + $ProviderSettingsPath, + [Parameter(Mandatory=$false)] [switch] $SkipSecrets ) process { + if(-not [string]::IsNullOrWhiteSpace($ProviderSettingsPath)){ + if(-not (Test-Path -Path $ProviderSettingsPath -PathType Leaf)){ + throw "Provider settings file [$ProviderSettingsPath] was not found." + } + + $ProviderSettings = Import-PowerShellDataFile -Path $ProviderSettingsPath + } + if(-not $SkipSecrets){ $ConfigurationData = Resolve-ConfigurationDataParameterSecrets -ConfigurationData $ConfigurationData -ProviderSettings $ProviderSettings } diff --git a/Public/Unregister-DSCConfigurationDataCredentialProvider.ps1 b/Public/Unregister-DSCConfigurationDataCredentialProvider.ps1 new file mode 100644 index 0000000..c921e46 --- /dev/null +++ b/Public/Unregister-DSCConfigurationDataCredentialProvider.ps1 @@ -0,0 +1,142 @@ +function Unregister-DSCConfigurationDataCredentialProvider { + [CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact="High")] + Param( + [Parameter(Mandatory=$true)] + [string] + $Vault, + + [Parameter(Mandatory=$false)] + [string] + $KeyPath, + + [Parameter(Mandatory=$false)] + [string] + $SettingsPath, + + [Parameter(Mandatory=$false)] + [switch] + $RemoveSettings, + + [Parameter(Mandatory=$false)] + [switch] + $RemoveKeyFile, + + [Parameter(Mandatory=$false)] + [switch] + $UnregisterVault, + + [Parameter(Mandatory=$false)] + [switch] + $ResetSecretStore, + + [Parameter(Mandatory=$false)] + [switch] + $Force, + + [Parameter(Mandatory=$false)] + [switch] + $PassThru + ) + + DynamicParam { + $ProviderNames = @(Get-ConfigurationDataSecretProviderName) + if($ProviderNames.Count -eq 0){ + $ProviderNames = @("__NoProvidersRegistered__") + } + + $ParameterAttribute = [System.Management.Automation.ParameterAttribute]::new() + $ParameterAttribute.Mandatory = $false + $ParameterAttribute.Position = 0 + + $ValidateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute -ArgumentList (,[string[]]$ProviderNames) + $Attributes = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() + $Attributes.Add($ParameterAttribute) + $Attributes.Add($ValidateSetAttribute) + + $RuntimeParameter = [System.Management.Automation.RuntimeDefinedParameter]::new( + "Provider", + [string], + $Attributes + ) + + $Dictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new() + $Dictionary.Add("Provider", $RuntimeParameter) + return $Dictionary + } + + begin { + $Provider = [string]$PSBoundParameters["Provider"] + if([string]::IsNullOrWhiteSpace($Provider)){ + $ProviderNames = @(Get-ConfigurationDataSecretProviderName) + if($ProviderNames.Count -eq 1){ + $Provider = $ProviderNames[0] + }elseif($ProviderNames.Count -eq 0){ + throw "No configuration data secret providers are registered." + }else{ + throw "Provider is required. Registered providers: $($ProviderNames -join ', ')." + } + } + + $SettingsPath = Resolve-ConfigurationDataProviderSettingsPath -Provider $Provider -SettingsPath $SettingsPath + } + + process { + $Result = [ordered]@{ + Provider = $Provider + Vault = $Vault + SettingsPath = $SettingsPath + RemovedSettings = $false + RemovedKeyFile = $false + UnregisteredVault = $false + ResetSecretStore = $false + } + + if($RemoveSettings -or (-not $PSBoundParameters.ContainsKey("RemoveSettings"))){ + if(Test-Path -Path $SettingsPath -PathType Leaf){ + if($PSCmdlet.ShouldProcess($SettingsPath, "Remove provider settings file")){ + Remove-Item -Path $SettingsPath -Force:$Force + $Result.RemovedSettings = $true + } + } + } + + if($RemoveKeyFile){ + if([string]::IsNullOrWhiteSpace($KeyPath)){ + throw "RemoveKeyFile requires [KeyPath]." + } + + if(Test-Path -Path $KeyPath -PathType Leaf){ + if($PSCmdlet.ShouldProcess($KeyPath, "Remove provider key file")){ + Remove-Item -Path $KeyPath -Force:$Force + $Result.RemovedKeyFile = $true + } + } + } + + if($UnregisterVault){ + if($Provider -notin @("SecretManagement", "SecretStore")){ + Write-Verbose "Provider [$Provider] does not register a SecretManagement vault." + }else{ + if($PSCmdlet.ShouldProcess($Vault, "Unregister SecretManagement vault")){ + Unregister-SecretVault -Name $Vault + $Result.UnregisteredVault = $true + } + } + } + + if($ResetSecretStore){ + if($Provider -ne "SecretStore"){ + Write-Verbose "ResetSecretStore is only applicable to provider [SecretStore]." + }else{ + if($PSCmdlet.ShouldProcess("SecretStore", "Reset local SecretStore and delete all contained secrets")){ + Reset-SecretStore -Force -Confirm:$false + $Result.ResetSecretStore = $true + } + } + } + + if($PassThru){ + return [PSCustomObject]$Result + } + } +} diff --git a/Readme.md b/Readme.md index 15cd047..6a0d576 100644 --- a/Readme.md +++ b/Readme.md @@ -111,7 +111,7 @@ FarmPassphrase = @{ Sensitive = $true Value = @{ Provider = 'KeePass' - Vault = 'BGW' + Vault = 'Contoso' Name = 'SharePoint/FarmPassphrase' } } @@ -122,9 +122,9 @@ SetupCredential = @{ Sensitive = $true Value = @{ Provider = 'KeePass' - Vault = 'BGW' - Name = 'SharePoint/SetupAccount' - UserName = 'BGW\SVC_SHP_SETUP' + Vault = 'Contoso' + Name = 'Application/SetupAccount' + UserName = 'CONTOSO\svc-app-setup' } } ``` @@ -134,13 +134,113 @@ Secret references are validated and resolved by `Resolve-DSCConfigurationData` b ```powershell $resolved = Resolve-DSCConfigurationData -ConfigurationData $merged -ProviderSettings @{ KeePass = @{ - DefaultVault = 'BGW' + DefaultVault = 'Contoso' } } ``` Use `-SkipSecrets` when you only want structural validation/resolution without loading provider secrets. +For unattended KeePass access, pass the KeePass master key through `ProviderSettings`, not through the PSD1 parameter definition: + +```powershell +$resolved = Resolve-DSCConfigurationData -ConfigurationData $merged -ProviderSettings @{ + KeePass = @{ + DefaultVault = 'Contoso' + MasterKey = (Get-Secret -Name 'KeePass-Contoso-MasterKey') + } +} +``` + +You can generate the provider settings file with: + +```powershell +Register-DSCConfigurationDataCredentialProvider ` + -Provider KeePass ` + -Vault Contoso ` + -KeyPath 'C:\DSC\Contoso\KeePass-Contoso.key' ` + -SettingsPath 'C:\DSC\Contoso' +``` + +SecretManagement can be initialized through the same entry point: + +```powershell +Register-DSCConfigurationDataCredentialProvider ` + -Provider SecretManagement ` + -Vault LocalStore ` + -ModuleName Microsoft.PowerShell.SecretStore ` + -RegisterVault ` + -DefaultVault ` + -SettingsPath 'C:\DSC\Contoso' +``` + +SecretStore can also be configured for unattended local usage: + +```powershell +Register-DSCConfigurationDataCredentialProvider ` + -Provider SecretStore ` + -Vault LocalStore ` + -ConfigureSecretStore ` + -Authentication None ` + -Interaction None ` + -RegisterVault ` + -DefaultVault ` + -SettingsPath 'C:\DSC\Contoso' +``` + +Provider setup can be removed again: + +```powershell +Unregister-DSCConfigurationDataCredentialProvider ` + -Provider KeePass ` + -Vault Contoso ` + -SettingsPath 'C:\DSC\Contoso' ` + -KeyPath 'C:\DSC\Contoso\KeePass-Contoso.key' ` + -RemoveKeyFile +``` + +For SecretManagement or SecretStore vault registration: + +```powershell +Unregister-DSCConfigurationDataCredentialProvider ` + -Provider SecretStore ` + -Vault LocalStore ` + -SettingsPath 'C:\DSC\Contoso' ` + -UnregisterVault +``` + +Use `-ResetSecretStore` only when you intentionally want to delete all secrets from the local SecretStore. + +Then use it directly: + +```powershell +$resolved = Resolve-DSCConfigurationData ` + -ConfigurationData $merged ` + -ProviderSettingsPath 'C:\DSC\Contoso\ProviderSettings.KeePass.psd1' +``` + +For portable unattended tests, use an AES key file with `ConvertFrom-SecureString -Key`: + +```powershell +$resolved = Resolve-DSCConfigurationData -ConfigurationData $merged -ProviderSettings @{ + KeePass = @{ + DefaultVault = 'Test' + MasterKey = @{ + ProtectedValue = '76492d1116743f0423413b16050a5345...' + KeyPath = 'F:\Secrets\KeePass-Test.key' + } + } +} +``` + +`MasterKey` also supports: + +```powershell +MasterKey = @{ + EnvironmentVariable = 'KEEPASS_TEST_MASTERKEY' +} +``` + ### Secret Provider Files Secret providers are loaded automatically from the module folder `Providers`. @@ -182,6 +282,42 @@ The KeePass provider is implemented in: Providers\Provider.KeePass.ps1 ``` +Built-in providers: + +- `KeePass`: uses `PoShKeePass` / `Get-KeePassEntry` +- `SecretManagement`: uses `Microsoft.PowerShell.SecretManagement` / `Get-Secret` +- `SecretStore`: convenience provider for local SecretStore vaults through `Get-Secret` + +SecretManagement example: + +```powershell +SetupCredential = @{ + Type = 'credential' + Required = $true + Sensitive = $true + Value = @{ + Provider = 'SecretManagement' + Vault = 'LocalStore' + Name = 'SharePointSetupCredential' + } +} +``` + +SecretStore example: + +```powershell +FarmPassphrase = @{ + Type = 'secureString' + Required = $true + Sensitive = $true + Value = @{ + Provider = 'SecretStore' + Vault = 'LocalStore' + Name = 'SharePointFarmPassphrase' + } +} +``` + Array values can be restricted item by item: ```powershell diff --git a/Resolve-DSCConfigurationData.psd1 b/Resolve-DSCConfigurationData.psd1 index 42ba29a..d4043f7 100644 --- a/Resolve-DSCConfigurationData.psd1 +++ b/Resolve-DSCConfigurationData.psd1 @@ -7,7 +7,9 @@ Description = "Resolves parameters, variables, and expressions in DSC configuration data." PowerShellVersion = "5.1" FunctionsToExport = @( - "Resolve-DSCConfigurationData" + "Resolve-DSCConfigurationData", + "Register-DSCConfigurationDataCredentialProvider", + "Unregister-DSCConfigurationDataCredentialProvider" ) CmdletsToExport = @() VariablesToExport = @() diff --git a/Resolve-DSCConfigurationData.psm1 b/Resolve-DSCConfigurationData.psm1 index ea80679..6f8861a 100644 --- a/Resolve-DSCConfigurationData.psm1 +++ b/Resolve-DSCConfigurationData.psm1 @@ -14,5 +14,7 @@ foreach($File in @($Private + $Providers + $Public)){ } Export-ModuleMember -Function @( - "Resolve-DSCConfigurationData" + "Resolve-DSCConfigurationData", + "Register-DSCConfigurationDataCredentialProvider", + "Unregister-DSCConfigurationDataCredentialProvider" )