SecretManagement.Hashicorp.Vault.KV.Extension/SecretManagement.Hashicorp.Vault.KV.Extension.psm1
# For ConvertTo-ReadOnlyDictonary using namespace System.Collections.ObjectModel using namespace System.Collections.Generic # Private Helper Functions enum HashicorpVaultConfigValues { VaultServer VaultAuthType VaultToken VaultAPIVersion KVVersion Verbose } enum HashicorpVaultAuthTypes { None AppRole LDAP userpass Token } class HashicorpVaultKV { static [string] $VaultServer static [HashicorpVaultAuthTypes] $VaultAuthType = 'None' static [Securestring] $VaultToken static [string] $VaultAPIVersion = 'v1' static [string] $KVVersion = 'v2' static [bool] $Verbose } function Invoke-CustomWebRequest { <# .SYNOPSIS Custom Web Request function to support non standard methods #> [Cmdletbinding()] param ( [Parameter(Mandatory)] [string]$Uri, [Parameter(Mandatory)] [object]$Headers, [Parameter(Mandatory)] [string]$Method ) Add-Type -AssemblyName System.Net.Http $Client = New-Object -TypeName System.Net.Http.HttpClient $Client.DefaultRequestHeaders.Accept.Add($headers['Accept']) $Request = New-Object -TypeName System.Net.Http.HttpRequestMessage $Request.Method = $method $Request.Headers.Add('X-Vault-Token', $headers['X-Vault-Token']) $Request.Headers.Add('ContentType', $headers['Content-type']) $Request.RequestUri = $Uri $Result = $Client.SendAsync($Request) $StatusCode = $Result.Result.StatusCode if ($StatusCode -eq "OK") { $Result.Result.Content.ReadAsStringAsync().Result | ConvertFrom-Json } else { Throw "$statuscode for $method on $uri" } $Client.Dispose() $Request.Dispose() } function ConvertTo-ReadOnlyDictionary { <# .SYNOPSIS Converts a hashtable to a ReadOnlyDictionary[String,Object]. Needed for SecretInformation .NOTES From Justin Grote at https://github.com/JustinGrote/SecretManagement.KeePass/blob/main/SecretManagement.KeePass.Extension/Private/ConvertTo-ReadOnlyDictionary.ps1 #> [CmdletBinding()] param( [Parameter(ValueFromPipeline)][hashtable]$hashtable ) process { $dictionary = [SortedDictionary[string, object]]::new([StringComparer]::OrdinalIgnoreCase) $hashtable.GetEnumerator().foreach{ $dictionary[$_.Name] = $_.Value } [ReadOnlyDictionary[string, object]]::new($dictionary) } } function Test-VaultVariable { <# .SYNOPSIS Ensures that all Static Variables are configured #> [Cmdletbinding()] param ( [Parameter()] [hashtable]$Arguments ) foreach ($k in $Arguments.GetEnumerator()) { if ($k.Key -notin [HashicorpVaultConfigValues].GetEnumNames()) { Write-Warning -Message "$($k.Key) not in accepted config values, skipping" continue } if ($k.key -eq 'VaultToken') { [HashicorpVaultKV]::$($k.Key) = $($k.Value | ConvertTo-SecureString) continue } if ($null -eq [HashicorpVaultKV]::$($k.key) -or [HashicorpVaultKV]::$($k.key) -ne $($k.key)) { [HashicorpVaultKV]::$($k.Key) = $k.Value } } } function Invoke-VaultToken { <# .SYNOPSIS Retrieves Token based on Supported Credential #> process { switch ([HashicorpVaultKV]::VaultAuthType) { "AppRole" { $Credential = Get-Credential -Message "Please Enter Role-Id and Secret-Id" $UserName = $Credential.UserName #Following TryParse from https://stackoverflow.com/a/62416925 $AppRoleResult = [System.Guid]::empty if (-not [System.Guid]::TryParse($UserName, [System.Management.Automation.PSReference]$AppRoleResult)) { throw "Approle Role-id must be a valid guid" } $UserLogin = "$([HashicorpVaultKV]::VaultServer)/$([HashicorpVaultKV]::VaultAPIVersion)/auth/approle/login" $UserPassword = "{`"role_id`":`"$UserName`",`"secret_id`":`"$($Credential.GetNetworkCredential().Password)`"}" continue } "LDAP" { $Credential = Get-Credential -Message "Please Enter LDAP credentials" $UserName = $Credential.UserName $UserLogin = "$([HashicorpVaultKV]::VaultServer)/$([HashicorpVaultKV]::VaultAPIVersion)/auth/ldap/login/$UserName" $UserPassword = "{`"password`":`"$($Credential.GetNetworkCredential().Password)`"}" continue } "Token" { [HashicorpVaultKV]::VaultToken = (Get-Credential -UserName Token -Message "Please Enter the token").Password break } "userpass" { $Credential = Get-Credential -Message "Please Enter UserName and Password credentials" $UserName = $Credential.UserName $UserLogin = "$([HashicorpVaultKV]::VaultServer)/$([HashicorpVaultKV]::VaultAPIVersion)/auth/userpass/login/$UserName" $UserPassword = "{`"password`":`"$($Credential.GetNetworkCredential().Password)`"}" continue } default { throw "This shouldn't be possible please create an issue on https://github.com/joshcorr/SecretManagement.Hashicorp.Vault.KV" } } try { if ([HashicorpVaultKV]::VaultAuthType -ne 'Token') { $auth = (Invoke-RestMethod -Method POST -Uri $UserLogin -Body $UserPassword) [HashicorpVaultKV]::VaultToken = $auth.auth.client_token | ConvertTo-SecureString -AsPlainText -Force } #Register an Event to prompt whent he token is expiring #Register-ObjectEvent } catch { throw } finally { $auth, $UserName, $UserPassword, $UserLogin, $Credential, $AppRoleResult = $null } } } function New-Vault { <# .SYNOPSIS Creates a new vault #> [CmdletBinding()] param ( [Parameter(ValueFromPipelineByPropertyName, Mandatory)] [string] $VaultName, [Parameter(ValueFromPipelineByPropertyName)] [hashtable] $AdditionalParameters ) process { try { $serverURI = $([HashicorpVaultKV]::VaultServer), $([HashicorpVaultKV]::VaultAPIVersion), 'sys/mounts', $VaultName -join '/' if ([HashicorpVaultKV]::KVVersion -eq 'v1') { $version = '1' } else { $version = '2' } $VaultSplat = @{ URI = $serverURI Method = 'POST' Headers = New-VaultAPIHeader } $VaultOptions = @{ type = 'kv' description = $AdditionalParameters['Description'] options = @{ version = $version } } $body = $VaultOptions | ConvertTo-Json if ($null -ne $body) { $VaultSplat['Body'] = $body } Invoke-RestMethod @VaultSplat } catch { throw } finally { #Probably unecessary, but precautionary. $VaultSplat, $VaultOption, $listuri, $uri, $Method, $Body = $null } } } function Remove-Vault { <# .SYNOPSIS Removes a vault #> [CmdletBinding()] param ( [Parameter(ValueFromPipelineByPropertyName, Mandatory)] [string] $VaultName, [Parameter(ValueFromPipelineByPropertyName)] [hashtable] $AdditionalParameters ) process { try { $serverURI = $([HashicorpVaultKV]::VaultServer), $([HashicorpVaultKV]::VaultAPIVersion), 'sys/mounts', $VaultName -join '/' Write-Verbose "Removing $VaultName. $AdditionalParameters['Description']" $VaultSplat = @{ URI = $serverURI Method = 'DELETE' Headers = New-VaultAPIHeader } Invoke-RestMethod @VaultSplat } catch { throw } finally { #Probably unecessary, but precautionary. $VaultSplat, $serverURI = $null } } } function New-VaultAPIHeader { <# .SYNOPSIS Creates a header for an API call .NOTES Token conversion From https://stackoverflow.com/a/57431985 #> @{ 'Content-Type' = 'application/json' 'Accept' = 'application/json' 'X-Vault-Token' = $([System.Net.NetworkCredential]::new("", $([HashicorpVaultKV]::VaultToken)).Password) } } function New-VaultAPIBody { <# .SYNOPSIS Creates the Body of an API call for Set-Secret #> [CmdletBinding()] param ( [Parameter()] [hashtable[]]$Data ) try { $CombinedData = @{} foreach ($ht in $Data.GetEnumerator()) { #Because of multiple Hashtables foreach ($d in $ht.GetEnumerator()) { if ($d.key -in $CombinedData.Keys ) { Write-Verbose -Message "Key: '$($d.key)' already provided" } if ($d.key -in @('cas', 'checkandset')) { $options = @{"cas" = [int]$d.value } } $CombinedData["$($d.key)"] = $d.value } } if ([HashicorpVaultKV]::KVVersion -eq 'v1') { $Tempbody = $CombinedData } elseif ([HashicorpVaultKV]::KVVersion -eq 'v2') { $Tempbody = @{ data = $CombinedData } if ($null -ne $options) { $Tempbody['options'] = $options } } $OutputBody = $Tempbody | ConvertTo-Json return $OutputBody } catch { throw } finally { $CombinedData, $OutputBody, $Tempbody, $options, $ht, $data = $Null } } function Resolve-VaultSecretPath { <# .SYNOPSIS Walks the Hashicorp KV strucutre to list secrets #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$VaultName, [Parameter()] [string]$Path ) $Data = (Invoke-VaultAPIQuery -VaultName $VaultName -SecretName $Path).data foreach ($k in $data.Keys) { $KeyPath = $Path, $k -join '/' if ($KeyPath.endswith('/')) { $ResolveSplat = @{ VaultName = $VaultName Path = $keyPath.Trim('/') } Resolve-VaultSecretPath @ResolveSplat } else { $KeyPath.TrimStart('/') } } } function Invoke-VaultAPIQuery { <# .SYNOPSIS Abstracts logic for which methods, and API calls should be done. #> [CmdletBinding()] param ( [Parameter()] [string]$VaultName, [Parameter()] [string]$SecretName, [Parameter()] [object]$SecretValue, [Parameter()] [hashtable]$Metadata ) try { $serverURI = $([HashicorpVaultKV]::VaultServer), $([HashicorpVaultKV]::VaultAPIVersion) -join '/' $baseURI = "$serverURI/$VaultName" $CallStack = (Get-PSCallStack)[1] $CallingCommand = $CallStack.Command $CallingVerb, $CallingNoun = ($CallingCommand -split '-') if ([HashicorpVaultKV]::KVVersion -eq 'v1') { $uri = "$baseURI/$SecretName" $listuri = "$baseURI/$SecretName" } elseif ([HashicorpVaultKV]::KVVersion -eq 'v2') { $uri = "$baseURI/data/$SecretName" $listuri = "$baseURI/metadata/$SecretName" } switch ($CallingVerb) { Get { $Method = 'GET' continue } Set { $Method = 'POST' if ($SecretName -match '/') { $Name = $($SecretName -split '/')[-1] } else { $Name = $SecretName } if ($SecretValue.GetType().Name -eq 'Hashtable') { $Body = New-VaultAPIBody -data $SecretValue, $Metadata } else { $Body = New-VaultAPIBody -data @{$Name = $SecretValue }, $Metadata } continue } Test { $method = 'GET' $uri = "$serverURI/sys/health", "$serverURI/sys/mounts" continue } Remove { $method = 'DELETE' # Deletes the secret like a KV version1 # KV version2 supports versions, which can't be implemented yet. # TODO provide a argument for type of action to take on KV v2 $uri = $listuri continue } Resolve { $method = 'LIST' $uri = $listuri continue } } $VaultSplat = @{ URI = $uri Method = $Method Headers = New-VaultAPIHeader } if ($null -ne $body) { $VaultSplat['Body'] = $body } if ($method -eq 'List') { Invoke-CustomWebRequest @VaultSplat } elseif ($CallingVerb -eq 'Test') { foreach ($u in $($uri -split ',')) { $VaultSplat['URI'] = $u Invoke-RestMethod @VaultSplat } } else { Invoke-RestMethod @VaultSplat } } catch { throw } finally { #Probably unecessary, but precautionary. $VaultSplat, $listuri, $uri, $Method, $Metadata, $Body = $null } } # Public functions function Get-Secret { [CmdletBinding()] param ( [Parameter(ValueFromPipelineByPropertyName)] [string] $Name, [Parameter(ValueFromPipelineByPropertyName)] [string] $VaultName, [Parameter(ValueFromPipelineByPropertyName)] [hashtable] $AdditionalParameters ) process { $null = Test-SecretVault -VaultName $VaultName -AdditionalParameters $AdditionalParameters if ($Name -match '/') { $SecretName = $($Name -split '/')[-1] } else { $SecretName = $Name } $SecretData = Invoke-VaultAPIQuery -VaultName $VaultName -SecretName $Name switch ([HashicorpVaultKV]::KVVersion) { 'v1' { if ($SecretData.data.psobject.properties.Name -notcontains $SecretName) { $Secret = $SecretData.data $SecretObject = [PSCredential]::new($Name, ($Secret | ConvertTo-SecureString -AsPlainText -Force)) } else { $Secret = $SecretData.data $SecretObject = [PSCredential]::new($Name, ($Secret.$SecretName | ConvertTo-SecureString -AsPlainText -Force)) } continue } 'v2' { if ($SecretData.data.data.psobject.properties.Name -notcontains $SecretName) { $Secret = $SecretData.data.data $SecretObject = [PSCredential]::new($Name, ($Secret | ConvertTo-SecureString -AsPlainText -Force)) } else { $Secret = $SecretData.data.data $SecretObject = [PSCredential]::new($Name, ($Secret.$SecretName | ConvertTo-SecureString -AsPlainText -Force)) } continue } default { throw "Unknown KeyVaule version" } } return $SecretObject } } function Get-SecretInfo { [CmdletBinding()] param ( [Parameter(ValueFromPipelineByPropertyName)] [string] $Filter, [Parameter(ValueFromPipelineByPropertyName)] [string] $VaultName, [Parameter(ValueFromPipelineByPropertyName)] [hashtable] $AdditionalParameters ) process { $null = Test-SecretVault -VaultName $VaultName -AdditionalParameters $AdditionalParameters $Filter = "*$Filter" $VaultSecrets = Resolve-VaultSecretPath -VaultName $VaultName $VaultSecrets | Where-Object { $PSItem -like $Filter } | ForEach-Object { if ([HashicorpVaultKV]::KVVersion -eq 'v1') { $Metadata = $null } else { $vault_metadata = (Invoke-VaultAPIQuery -VaultName $VaultName -SecretName $PSItem).data.metadata $Metadata = [Ordered]@{} $vault_metadata.psobject.properties | ForEach-Object { $Metadata[$PSItem.Name] = $PSItem.Value } $Dictonary = ConvertTo-ReadOnlyDictionary -hashtable $Metadata } [Microsoft.PowerShell.SecretManagement.SecretInformation]::new( "$PSItem", "String", $VaultName, $Dictonary) } } } function Set-Secret { [CmdletBinding()] param ( [Parameter(ValueFromPipelineByPropertyName)] [string] $Name, [Parameter(ValueFromPipelineByPropertyName)] [object] $Secret, [Parameter(ValueFromPipelineByPropertyName)] [string] $VaultName, [Parameter(ValueFromPipelineByPropertyName)] [hashtable] $AdditionalParameters, [Parameter(ValueFromPipelineByPropertyName)] [hashtable] $Metadata ) process { $null = Test-SecretVault -VaultName $VaultName -AdditionalParameters $AdditionalParameters switch ($Secret.GetType()) { 'String' { $SecretValue = $Secret continue } 'SecureString' { $SecretValue = $Secret | ConvertFrom-SecureString -AsPlainText continue } 'PSCredential' { $SecretValue = $Secret.Password | ConvertFrom-SecureString -AsPlainText continue } 'Hashtable' { $SecretValue = $Secret continue } default { throw "Unsupported secret type: $($Secret.GetType().Name)" } } $SecretData = Invoke-VaultAPIQuery -VaultName $VaultName -SecretName $Name -SecretValue $SecretValue -Metadata $Metadata #$? represents the success/fail of the last execution if (-not $?) { throw $SecretData } return $? } } function Remove-Secret { [CmdletBinding()] param ( [Parameter(ValueFromPipelineByPropertyName)] [string] $Name, [Parameter(ValueFromPipelineByPropertyName)] [string] $VaultName, [Parameter(ValueFromPipelineByPropertyName)] [hashtable] $AdditionalParameters ) process { $null = Test-SecretVault -VaultName $VaultName -AdditionalParameters $AdditionalParameters $SecretData = Invoke-VaultAPIQuery -VaultName $VaultName -SecretName $Name #$? represents the success/fail of the last execution if (-not $?) { throw $SecretData } return $? } } function Test-SecretVault { [CmdletBinding()] param ( [Parameter(ValueFromPipelineByPropertyName, Mandatory)] [string] $VaultName, [Parameter(ValueFromPipelineByPropertyName)] [hashtable] $AdditionalParameters ) process { $ErrorActionPreference = 'STOP' Test-VaultVariable -Arguments $AdditionalParameters if ($null -eq [HashicorpVaultKV]::VaultServer) { [HashicorpVaultKV]::VaultServer = Read-Host -Prompt "Please provide the URL for the HashiCorp Vault (Example: https://myvault.domain.local)" } if ('None' -eq [HashicorpVaultKV]::VaultAuthType) { [HashicorpVaultKV]::VaultAuthType = Read-Host -Prompt "Please provide the AuthType for your HashiCorp Vault. Supported Types: $([HashicorpVaultAuthTypes].GetEnumNames())" } if ($Null -eq [HashicorpVaultKV]::VaultToken) { Invoke-VaultToken } #The rest runs provided the top 4 items are correct try { $VaultHealth = (Invoke-VaultAPIQuery -VaultName $VaultName) } catch { $CheckError = $PSItem.Exception.Response.StatusCode.value__ $URL = $PSItem.TargetObject.RequestURI.AbsoluteUri #Right from https://www.vaultproject.io/api-docs#http-status-codes Switch ($CheckError) { '400' { Write-Warning -Message "$URL; Invalid request, missing or invalid data" ; continue } '403' { Write-Warning -Message "$URL; Forbidden, your authentication details are either incorrect, you don't have access to this feature, or - if CORS is enabled - you made a cross-origin request from an origin that is not allowed to make such requests."; continue } '404' { Write-Warning -Message "$URL; Invalid path. This can both mean that the path truly doesn't exist or that you don't have permission to view a specific path."; continue } '429' { Write-Warning -Message "$URL; Default return code for health status of standby nodes"; continue } '473' { Write-Warning -Message "$URL; Default return code for health status of performance standby nodes"; continue } '500' { Write-Warning -Message "$URL; Internal server error. An internal error has occurred, try again later."; continue } '502' { Write-Warning -Message "$URL; A request to Vault required Vault making a request to a third party; the third party responded with an error of some kind."; continue } '503' { Write-Warning -Message "$URL; Vault is down for maintenance or is currently sealed. Try again later."; continue } default { throw "$URL; Something occured while communicating with $([HashicorpVaultKV]::VaultServer)" } } } if ($CheckError -notin @('403', '404')) { if ($VaultHealth[0].sealed -eq 'True') { Throw "The Hashicorp Vault at $([HashicorpVaultKV]::VaultServer) is sealed" } #This should return $null if the vault doesn't exist if ($VaultHealth[1].Gettype().Name -eq 'PSCustomObject' ) { #Some older version may not support this method $SelectedVault = $VaultHealth[1].$("$VaultName/") } else { $SelectedVault = $VaultHealth[1] -Match "$VaultName/" } if ($null -eq $SelectedVault) { #Create Vault if one specified doesn't exist $Response = Read-Host -Prompt "$VaultName does not exist on $([HashicorpVaultKV]::VaultServer). Attempt to create it? (Yes/No)" if ($Response -imatch '^Y$|^yes$') { New-Vault -VaultName $VaultName -AdditionalParameters $AdditionalParameters } } } return $? } } function Unregister-SecretVault { [CmdletBinding()] param ( [Parameter(ValueFromPipelineByPropertyName)] [string] $VaultName, [Parameter(ValueFromPipelineByPropertyName)] [hashtable] $AdditionalParameters ) process { $null = Test-SecretVault -VaultName $VaultName -AdditionalParameters $AdditionalParameters $Response = Read-Host -Prompt "Do you want to disable $VaultName on $([HashicorpVaultKV]::VaultServer) as well? (Yes/No) NOTE: This will remove all Secrets" if ($Response -imatch '^Y$|^yes$') { Remove-Vault -VaultName $VaultName -AdditionalParameters $AdditionalParameters } } } |