diff --git a/Private/Assert-ConfigurationDataParameterType.ps1 b/Private/Assert-ConfigurationDataParameterType.ps1 index 5dff7bf..d62cb53 100644 --- a/Private/Assert-ConfigurationDataParameterType.ps1 +++ b/Private/Assert-ConfigurationDataParameterType.ps1 @@ -20,6 +20,11 @@ function Assert-ConfigurationDataParameterType { switch($TypeName){ "string" { + if(Test-ConfigurationDataSecretReference -Value $Value){ + Assert-ConfigurationDataSecretReference -Name $Name -TypeName "string" -Value $Value + return + } + if($Value -isnot [string]){ throw "Parameter [$Name] expects type [string], but received [$($Value.GetType().Name)]." } diff --git a/Private/Assert-ConfigurationDataSecretReference.ps1 b/Private/Assert-ConfigurationDataSecretReference.ps1 index a743da1..6ea4163 100644 --- a/Private/Assert-ConfigurationDataSecretReference.ps1 +++ b/Private/Assert-ConfigurationDataSecretReference.ps1 @@ -6,7 +6,7 @@ function Assert-ConfigurationDataSecretReference { $Name, [Parameter(Mandatory=$true)] - [ValidateSet("credential", "securestring")] + [ValidateSet("credential", "securestring", "string")] [string] $TypeName, diff --git a/Private/Resolve-ConfigurationDataParameterSecrets.ps1 b/Private/Resolve-ConfigurationDataParameterSecrets.ps1 index 4c4e2ee..c5c420f 100644 --- a/Private/Resolve-ConfigurationDataParameterSecrets.ps1 +++ b/Private/Resolve-ConfigurationDataParameterSecrets.ps1 @@ -25,7 +25,7 @@ function Resolve-ConfigurationDataParameterSecrets { $ResolvedConfigurationData.Parameters[$Parameter.Name] = $Definition $TypeName = [string](Get-ConfigurationDataMapValue -Map $Definition -Key "Type" -DefaultValue "") - if($TypeName -notin @("credential", "secureString")){ + if($TypeName -notin @("credential", "secureString", "string")){ continue } diff --git a/Private/Resolve-ConfigurationDataProviderSettingsPath.ps1 b/Private/Resolve-ConfigurationDataProviderSettingsPath.ps1 index 288e073..a3d2a38 100644 --- a/Private/Resolve-ConfigurationDataProviderSettingsPath.ps1 +++ b/Private/Resolve-ConfigurationDataProviderSettingsPath.ps1 @@ -17,7 +17,10 @@ function Resolve-ConfigurationDataProviderSettingsPath { return (Join-Path -Path (Get-Location).Path -ChildPath $FileName) } - if((Test-Path -Path $SettingsPath -PathType Container) -or $SettingsPath.EndsWith("\") -or $SettingsPath.EndsWith("/")){ + if((Test-Path -Path $SettingsPath -PathType Container) -or + $SettingsPath.EndsWith("\") -or + $SettingsPath.EndsWith("/") -or + [string]::IsNullOrWhiteSpace([System.IO.Path]::GetExtension($SettingsPath))){ return (Join-Path -Path $SettingsPath -ChildPath $FileName) } diff --git a/Providers/Provider.AzureKeyVault.ps1 b/Providers/Provider.AzureKeyVault.ps1 new file mode 100644 index 0000000..65452d7 --- /dev/null +++ b/Providers/Provider.AzureKeyVault.ps1 @@ -0,0 +1,94 @@ +$AzureKeyVaultProvider = @{ + Name = "AzureKeyVault" + 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-AzKeyVaultSecret -ErrorAction SilentlyContinue + if($null -eq $GetSecretCommand){ + throw "AzureKeyVault provider requires the module [Az.KeyVault] and command [Get-AzKeyVaultSecret]." + } + + $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 + } + + if([string]::IsNullOrWhiteSpace($Vault)){ + throw "AzureKeyVault secret [$Name] requires [Vault] or provider setting [DefaultVault]." + } + + if($ProviderSettings.ContainsKey("SubscriptionId") -or $ProviderSettings.ContainsKey("TenantId")){ + $SetContextCommand = Get-Command -Name Set-AzContext -ErrorAction SilentlyContinue + if($null -eq $SetContextCommand){ + throw "AzureKeyVault provider settings define Azure context values, but command [Set-AzContext] was not found. Install [Az.Accounts] or remove SubscriptionId/TenantId from provider settings." + } + + $ContextParameters = @{} + if($ProviderSettings.ContainsKey("SubscriptionId") -and -not [string]::IsNullOrWhiteSpace([string]$ProviderSettings.SubscriptionId)){ + $ContextParameters["SubscriptionId"] = [string]$ProviderSettings.SubscriptionId + } + + if($ProviderSettings.ContainsKey("TenantId") -and -not [string]::IsNullOrWhiteSpace([string]$ProviderSettings.TenantId)){ + $ContextParameters["Tenant"] = [string]$ProviderSettings.TenantId + } + + if($ContextParameters.Count -gt 0){ + & $SetContextCommand @ContextParameters | Out-Null + } + } + + $CommandParameters = @{ + VaultName = $Vault + Name = $Name + } + + if($Options -is [System.Collections.IDictionary]){ + foreach($Key in $Options.Keys){ + $CommandParameters[$Key] = $Options[$Key] + } + } + + if($ExpectedType -eq "string" -and $GetSecretCommand.Parameters.ContainsKey("AsPlainText") -and -not $CommandParameters.ContainsKey("AsPlainText")){ + $CommandParameters["AsPlainText"] = $true + } + + $Secret = & $GetSecretCommand @CommandParameters + if($null -eq $Secret){ + throw "Azure Key Vault secret [$Name] was not found in vault [$Vault]." + } + + if($ExpectedType -eq "string" -and $Secret -is [string]){ + return $Secret + } + + if($Secret.PSObject.Properties["SecretValue"]){ + return ConvertFrom-ConfigurationDataSecretValue -Secret $Secret.SecretValue -ExpectedType $ExpectedType -Name $Name -UserName $UserName + } + + return ConvertFrom-ConfigurationDataSecretValue -Secret $Secret -ExpectedType $ExpectedType -Name $Name -UserName $UserName + } +} + +Register-ConfigurationDataSecretProvider @AzureKeyVaultProvider diff --git a/Public/Register-DSCConfigurationDataCredentialProvider.ps1 b/Public/Register-DSCConfigurationDataCredentialProvider.ps1 index d85493f..c918dcb 100644 --- a/Public/Register-DSCConfigurationDataCredentialProvider.ps1 +++ b/Public/Register-DSCConfigurationDataCredentialProvider.ps1 @@ -25,6 +25,14 @@ function Register-DSCConfigurationDataCredentialProvider { [hashtable] $VaultParameters = @{}, + [Parameter(Mandatory=$false)] + [string] + $SubscriptionId, + + [Parameter(Mandatory=$false)] + [string] + $TenantId, + [Parameter(Mandatory=$false)] [switch] $RegisterVault, @@ -239,6 +247,29 @@ function Register-DSCConfigurationDataCredentialProvider { return $ProviderSettings } } + "AzureKeyVault" { + $ProviderSettings = [ordered]@{ + AzureKeyVault = [ordered]@{ + DefaultVault = $Vault + } + } + + if(-not [string]::IsNullOrWhiteSpace($SubscriptionId)){ + $ProviderSettings["AzureKeyVault"]["SubscriptionId"] = $SubscriptionId + } + + if(-not [string]::IsNullOrWhiteSpace($TenantId)){ + $ProviderSettings["AzureKeyVault"]["TenantId"] = $TenantId + } + + 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/Readme.md b/Readme.md index 6a0d576..da33582 100644 --- a/Readme.md +++ b/Readme.md @@ -188,6 +188,17 @@ Register-DSCConfigurationDataCredentialProvider ` -SettingsPath 'C:\DSC\Contoso' ``` +Azure Key Vault can be registered as a settings file without storing Azure credentials. Authentication is expected to come from the active Az context, managed identity, service principal login, or another external Azure authentication flow: + +```powershell +Register-DSCConfigurationDataCredentialProvider ` + -Provider AzureKeyVault ` + -Vault contoso-kv ` + -SubscriptionId '00000000-0000-0000-0000-000000000000' ` + -TenantId '11111111-1111-1111-1111-111111111111' ` + -SettingsPath 'C:\DSC\Contoso' +``` + Provider setup can be removed again: ```powershell @@ -287,6 +298,7 @@ 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` +- `AzureKeyVault`: uses `Az.KeyVault` / `Get-AzKeyVaultSecret` SecretManagement example: @@ -318,6 +330,33 @@ FarmPassphrase = @{ } ``` +Azure Key Vault example: + +```powershell +SetupCredential = @{ + Type = 'credential' + Required = $true + Sensitive = $true + Value = @{ + Provider = 'AzureKeyVault' + Vault = 'contoso-kv' + Name = 'app-setup-password' + UserName = 'CONTOSO\svc-app-setup' + } +} + +FarmPassphrase = @{ + Type = 'secureString' + Required = $true + Sensitive = $true + Value = @{ + Provider = 'AzureKeyVault' + Vault = 'contoso-kv' + Name = 'farm-passphrase' + } +} +``` + Array values can be restricted item by item: ```powershell diff --git a/Tests/Provider.Tests.ps1 b/Tests/Provider.Tests.ps1 new file mode 100644 index 0000000..69e016e --- /dev/null +++ b/Tests/Provider.Tests.ps1 @@ -0,0 +1,278 @@ +$script:ModuleRoot = Split-Path -Parent $PSScriptRoot +$script:ModuleManifest = Join-Path -Path $script:ModuleRoot -ChildPath 'Resolve-DSCConfigurationData.psd1' + +Import-Module $script:ModuleManifest -Force + +function global:Get-KeePassEntry { + param( + [string] $DatabaseProfileName, + [string] $Title, + [System.Security.SecureString] $MasterKey + ) + + switch($Title){ + 'SetupAccount' { + [pscustomobject]@{ + UserName = 'CONTOSO\svc-setup' + Password = 'KeePass-Setup-Password!' + } + } + 'FarmPassphrase' { + [pscustomobject]@{ + UserName = '' + Password = 'KeePass-Farm-Passphrase!' + } + } + default { + $null + } + } +} + +function global:Get-Secret { + param( + [string] $Vault, + [string] $Name + ) + + switch($Name){ + 'SetupCredential' { + [pscredential]::new( + 'CONTOSO\svc-secret-setup', + (ConvertTo-SecureString -String 'Secret-Setup-Password!' -AsPlainText -Force) + ) + } + 'FarmPassphrase' { + ConvertTo-SecureString -String 'Secret-Farm-Passphrase!' -AsPlainText -Force + } + 'PlainSecret' { + 'Secret-Plain-Value!' + } + default { + $null + } + } +} + +function global:Get-AzKeyVaultSecret { + param( + [string] $VaultName, + [string] $Name, + [switch] $AsPlainText + ) + + if($AsPlainText){ + return "Az-$Name-Plain!" + } + + [pscustomobject]@{ + SecretValue = ConvertTo-SecureString -String "Az-$Name-Secure!" -AsPlainText -Force + } +} + +function global:Set-AzContext { + param( + [string] $SubscriptionId, + [string] $Tenant + ) + + [pscustomobject]@{ + SubscriptionId = $SubscriptionId + Tenant = $Tenant + } +} + +function ConvertFrom-TestSecureString { + param( + [Parameter(Mandatory)] + [System.Security.SecureString] + $SecureString + ) + + $Pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) + try { + [Runtime.InteropServices.Marshal]::PtrToStringBSTR($Pointer) + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Pointer) + } +} + +function New-TestConfigurationData { + param( + [Parameter(Mandatory)] + [string] + $Provider, + + [Parameter(Mandatory)] + [string] + $Vault, + + [Parameter(Mandatory)] + [string] + $CredentialName, + + [Parameter(Mandatory)] + [string] + $PassphraseName, + + [Parameter(Mandatory)] + [string] + $PlainSecretName, + + [string] + $UserName = '' + ) + + $CredentialReference = @{ + Provider = $Provider + Vault = $Vault + Name = $CredentialName + } + + if(-not [string]::IsNullOrWhiteSpace($UserName)){ + $CredentialReference.UserName = $UserName + } + + @{ + Parameters = @{ + SetupCredential = @{ + Type = 'credential' + Value = $CredentialReference + } + FarmPassphrase = @{ + Type = 'secureString' + Value = @{ + Provider = $Provider + Vault = $Vault + Name = $PassphraseName + } + } + PlainSecret = @{ + Type = 'string' + Value = @{ + Provider = $Provider + Vault = $Vault + Name = $PlainSecretName + } + } + } + Resources = @{ + Test = @{ + SetupCredential = "[parameters('SetupCredential')]" + FarmPassphrase = "[parameters('FarmPassphrase')]" + PlainSecret = "[parameters('PlainSecret')]" + } + } + } +} + +Describe 'DSC ConfigurationData credential providers' { + It 'registers all built-in providers in the dynamic ValidateSet' { + $ProviderValues = (Get-Command Register-DSCConfigurationDataCredentialProvider). + Parameters['Provider']. + Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } | + ForEach-Object { $_.ValidValues } + + ($ProviderValues -contains 'AzureKeyVault') | Should Be $true + ($ProviderValues -contains 'KeePass') | Should Be $true + ($ProviderValues -contains 'SecretManagement') | Should Be $true + ($ProviderValues -contains 'SecretStore') | Should Be $true + } + + It 'writes Azure Key Vault provider settings without storing credentials' { + $SettingsPath = Join-Path -Path $TestDrive -ChildPath 'ProviderSettings' + + $Settings = Register-DSCConfigurationDataCredentialProvider ` + -Provider AzureKeyVault ` + -Vault contoso-kv ` + -SubscriptionId '00000000-0000-0000-0000-000000000000' ` + -TenantId '11111111-1111-1111-1111-111111111111' ` + -SettingsPath $SettingsPath ` + -Force ` + -PassThru + + $Settings.AzureKeyVault.DefaultVault | Should Be 'contoso-kv' + $Settings.AzureKeyVault.SubscriptionId | Should Be '00000000-0000-0000-0000-000000000000' + $Settings.AzureKeyVault.TenantId | Should Be '11111111-1111-1111-1111-111111111111' + $Settings.AzureKeyVault.Contains('ClientSecret') | Should Be $false + + (Join-Path -Path $SettingsPath -ChildPath 'ProviderSettings.AzureKeyVault.psd1') | Should Exist + } + + It 'resolves KeePass credential, secureString, and string references' { + $ConfigurationData = New-TestConfigurationData ` + -Provider KeePass ` + -Vault Test ` + -CredentialName SetupAccount ` + -PassphraseName FarmPassphrase ` + -PlainSecretName FarmPassphrase + + $Resolved = Resolve-DSCConfigurationData -ConfigurationData $ConfigurationData + + ($Resolved.Resources.Test.SetupCredential -is [System.Management.Automation.PSCredential]) | Should Be $true + $Resolved.Resources.Test.SetupCredential.UserName | Should Be 'CONTOSO\svc-setup' + $Resolved.Resources.Test.SetupCredential.GetNetworkCredential().Password | Should Be 'KeePass-Setup-Password!' + ConvertFrom-TestSecureString -SecureString $Resolved.Resources.Test.FarmPassphrase | Should Be 'KeePass-Farm-Passphrase!' + $Resolved.Resources.Test.PlainSecret | Should Be 'KeePass-Farm-Passphrase!' + } + + It 'resolves SecretManagement credential, secureString, and string references' { + $ConfigurationData = New-TestConfigurationData ` + -Provider SecretManagement ` + -Vault LocalStore ` + -CredentialName SetupCredential ` + -PassphraseName FarmPassphrase ` + -PlainSecretName PlainSecret + + $Resolved = Resolve-DSCConfigurationData -ConfigurationData $ConfigurationData + + ($Resolved.Resources.Test.SetupCredential -is [System.Management.Automation.PSCredential]) | Should Be $true + $Resolved.Resources.Test.SetupCredential.UserName | Should Be 'CONTOSO\svc-secret-setup' + $Resolved.Resources.Test.SetupCredential.GetNetworkCredential().Password | Should Be 'Secret-Setup-Password!' + ConvertFrom-TestSecureString -SecureString $Resolved.Resources.Test.FarmPassphrase | Should Be 'Secret-Farm-Passphrase!' + $Resolved.Resources.Test.PlainSecret | Should Be 'Secret-Plain-Value!' + } + + It 'resolves SecretStore credential, secureString, and string references' { + $ConfigurationData = New-TestConfigurationData ` + -Provider SecretStore ` + -Vault LocalStore ` + -CredentialName SetupCredential ` + -PassphraseName FarmPassphrase ` + -PlainSecretName PlainSecret + + $Resolved = Resolve-DSCConfigurationData -ConfigurationData $ConfigurationData + + ($Resolved.Resources.Test.SetupCredential -is [System.Management.Automation.PSCredential]) | Should Be $true + $Resolved.Resources.Test.SetupCredential.UserName | Should Be 'CONTOSO\svc-secret-setup' + $Resolved.Resources.Test.SetupCredential.GetNetworkCredential().Password | Should Be 'Secret-Setup-Password!' + ConvertFrom-TestSecureString -SecureString $Resolved.Resources.Test.FarmPassphrase | Should Be 'Secret-Farm-Passphrase!' + $Resolved.Resources.Test.PlainSecret | Should Be 'Secret-Plain-Value!' + } + + It 'resolves Azure Key Vault credential, secureString, and string references' { + $ConfigurationData = New-TestConfigurationData ` + -Provider AzureKeyVault ` + -Vault contoso-kv ` + -CredentialName setup-password ` + -PassphraseName farm-passphrase ` + -PlainSecretName plain-secret ` + -UserName 'CONTOSO\svc-app-setup' + + $Resolved = Resolve-DSCConfigurationData -ConfigurationData $ConfigurationData -ProviderSettings @{ + AzureKeyVault = @{ + DefaultVault = 'contoso-kv' + SubscriptionId = '00000000-0000-0000-0000-000000000000' + TenantId = '11111111-1111-1111-1111-111111111111' + } + } + + ($Resolved.Resources.Test.SetupCredential -is [System.Management.Automation.PSCredential]) | Should Be $true + $Resolved.Resources.Test.SetupCredential.UserName | Should Be 'CONTOSO\svc-app-setup' + $Resolved.Resources.Test.SetupCredential.GetNetworkCredential().Password | Should Be 'Az-setup-password-Secure!' + ConvertFrom-TestSecureString -SecureString $Resolved.Resources.Test.FarmPassphrase | Should Be 'Az-farm-passphrase-Secure!' + $Resolved.Resources.Test.PlainSecret | Should Be 'Az-plain-secret-Plain!' + } +}