Store.psm1

[CmdletBinding()]
param()

$scriptName = 'Store'
Write-Verbose "[$scriptName] - Importing module"

#region - From [functions] - [private]
Write-Verbose "[$scriptName] - [functions] - [private] - Processing folder"

#region - From [functions] - [private] - [Initialize-SecretVault]
Write-Verbose "[$scriptName] - [functions] - [private] - [Initialize-SecretVault] - Importing"

#Requires -Modules Microsoft.PowerShell.SecretManagement
#Requires -Modules Microsoft.PowerShell.SecretStore

function Initialize-SecretVault {
    <#
        .SYNOPSIS
        Initialize a SecretStore with open config.

        .DESCRIPTION
        Initialize a secret vault. If the vault does not exist, it will be created and registered.

        The SecretStore is created with the following parameters:
        - Authentication: None
        - PasswordTimeout: -1 (infinite)
        - Interaction: None
        - Scope: CurrentUser

        .EXAMPLE
        Initialize-SecretStore

        Initializes a secret vault named 'SecretStore' using the 'Microsoft.PowerShell.SecretStore' module.

        .NOTES
        For more information about secret vaults, see
        https://learn.microsoft.com/en-us/powershell/utility-modules/secretmanagement/overview?view=ps-modules
    #>

    [OutputType([Microsoft.PowerShell.SecretManagement.SecretVaultInfo])]
    [CmdletBinding()]
    param (
        # The name of the secret vault.
        [Parameter()]
        [string] $Name = $script:Config.SecretVaultName,

        # The type of the secret vault.
        [Parameter()]
        [string] $Type = $script:Config.SecretVaultType
    )
    $vault = Get-SecretVault | Where-Object { $_.ModuleName -eq $Type }
    if (-not $vault) {
        Write-Verbose "[$Type] - Configuring vault type"

        $vaultParameters = @{
            Authentication  = 'None'
            PasswordTimeout = -1
            Interaction     = 'None'
            Scope           = 'CurrentUser'
            WarningAction   = 'SilentlyContinue'
            Confirm         = $false
            Force           = $true
        }
        Reset-SecretStore @vaultParameters
        Write-Verbose "[$Type] - Done"

        Write-Verbose "[$Name] - Registering vault"
        $secretVault = @{
            Name         = $Name
            ModuleName   = $Type
            DefaultVault = $true
            Description  = 'SecretStore'
        }
        Register-SecretVault @secretVault
        Write-Verbose "[$Name] - Done"
    } else {
        Write-Verbose "[$Name] - Vault already registered"
    }

    Get-SecretVault | Where-Object { $_.ModuleName -eq $Type }

}

Write-Verbose "[$scriptName] - [functions] - [private] - [Initialize-SecretVault] - Done"
#endregion - From [functions] - [private] - [Initialize-SecretVault]

Write-Verbose "[$scriptName] - [functions] - [private] - Done"
#endregion - From [functions] - [private]

#region - From [functions] - [public]
Write-Verbose "[$scriptName] - [functions] - [public] - Processing folder"

#region - From [functions] - [public] - [Store]
Write-Verbose "[$scriptName] - [functions] - [public] - [Store] - Processing folder"

#region - From [functions] - [public] - [Store] - [Get-Store]
Write-Verbose "[$scriptName] - [functions] - [public] - [Store] - [Get-Store] - Importing"

function Get-Store {
    <#
        .SYNOPSIS
        Retrieves secrets from a specified secret vault.

        .DESCRIPTION
        The `Get-Store` cmdlet retrieves secrets from a specified secret vault.
        You can specify the name of the secret to retrieve or use a wildcard pattern to retrieve multiple secrets.
        If no name is specified, all secrets from the vault will be retrieved.
        Optionally, you can choose to retrieve the secrets as plain text.

        .EXAMPLE
        Get-Store

        Get all stores from the vault.

        .EXAMPLE
        Get-Store -Name 'MySecret'

        Get the store called 'MySecret' from the vault.

        .EXAMPLE
        Get-Store -Name 'My*'

        Get all stores that match the pattern 'My*' from the vault.
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param (
        # The name of the secret to retrieve from the vault. Supports wildcard patterns.
        [Parameter()]
        [string] $Name,

        # Switch to retrieve the secrets as plain text.
        [Parameter()]
        [switch] $AsPlainText
    )

    Write-Verbose "Retrieving secret vault with name [$($script:Config.SecretVaultName)]"
    $secretVault = Get-SecretVault | Where-Object { $_.Name -eq $script:Config.SecretVaultName }
    if (-not $secretVault) {
        Write-Verbose "No secret vault found with name [$($script:Config.SecretVaultName)]"
        return $null
    }

    Write-Verbose "Retrieving secret infos from vault [$($secretVault.Name)]"
    $secretInfos = Get-SecretInfo -Vault $secretVault.Name
    if (-not $secretInfos) {
        Write-Verbose "No secret infos found in vault [$($secretVault.Name)]"
        return $null
    }

    if ($Name) {
        Write-Verbose "Filtering secret infos with name pattern [$Name]"
        $secretInfos = $secretInfos | Where-Object { $_.Name -like $Name }
    }

    $stores = @()
    foreach ($secretInfo in $secretInfos) {
        $metadata = $secretInfo | Select-Object -ExpandProperty Metadata
        $store = $metadata + @{
            Name   = $secretInfo.Name
            Secret = Get-Secret -Name $secretInfo.Name -Vault $script:Config.SecretVaultName -AsPlainText:$AsPlainText
        }
        $stores += [pscustomobject]$store
    }

    return $stores
}

# Register tab completer for the Name parameter
Register-ArgumentCompleter -CommandName Get-Store -ParameterName Name -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $null)
    $null = $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters # Suppress unused variable warning
    $secretVault = Get-SecretVault | Where-Object { $_.Name -eq $script:Config.SecretVaultName }
    if (-not $secretVault) {
        return
    }

    $secretInfos = Get-SecretInfo -Vault $secretVault.Name
    if (-not $secretInfos) {
        return
    }

    $secretInfos | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {
        [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $_.Name)
    }
}

Write-Verbose "[$scriptName] - [functions] - [public] - [Store] - [Get-Store] - Done"
#endregion - From [functions] - [public] - [Store] - [Get-Store]
#region - From [functions] - [public] - [Store] - [Remove-Store]
Write-Verbose "[$scriptName] - [functions] - [public] - [Store] - [Remove-Store] - Importing"

filter Remove-Store {
    <#
        .SYNOPSIS
        Remove a store from the vault.

        .DESCRIPTION
        This function removes a store from the vault. It supports removing a single store by name,
        multiple stores using wildcard patterns, and can also accept input from the pipeline.
        If the specified store(s) exist, they will be removed from the vault.

        .EXAMPLE
        Remove-Store -Name 'MySecret'

        Removes the store called 'MySecret' from the vault.

        .EXAMPLE
        'MySecret*' | Remove-Store

        Removes all stores matching the pattern 'MySecret*' from the vault.

        .EXAMPLE
        Get-Store -Name 'MySecret*' | Remove-Store

        Retrieves all stores matching the pattern 'MySecret*' and removes them from the vault.
    #>

    [OutputType([void])]
    [CmdletBinding(SupportsShouldProcess)]
    param (
        # The name of the secret vault.
        [Parameter(
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [string] $Name
    )

    $secretVault = Get-SecretVault | Where-Object { $_.Name -eq $script:Config.SecretVaultName }
    if (-not $secretVault) {
        Write-Error 'Secret vault not found.'
        return
    }

    $secretInfos = Get-SecretInfo -Vault $secretVault.Name | Where-Object { $_.Name -like $Name }
    if (-not $secretInfos) {
        Write-Error 'No matching stores found.'
        return
    }

    foreach ($secretInfo in $secretInfos) {
        if ($PSCmdlet.ShouldProcess('Remove-Secret', $secretInfo.Name)) {
            Remove-Secret -Name $secretInfo.Name -Vault $script:Config.SecretVaultName
        }
    }
}

Write-Verbose "[$scriptName] - [functions] - [public] - [Store] - [Remove-Store] - Done"
#endregion - From [functions] - [public] - [Store] - [Remove-Store]
#region - From [functions] - [public] - [Store] - [Set-Store]
Write-Verbose "[$scriptName] - [functions] - [public] - [Store] - [Set-Store] - Importing"

function Set-Store {
    <#
        .SYNOPSIS
        Set a store in the vault.

        .DESCRIPTION
        If the store does not exist, it will be created. If it already exists, it will be updated.

        .EXAMPLE
        Set-Store -Name 'MySecret'

        Create a store called 'MySecret' in the vault.

        .EXAMPLE
        Set-Store -Name 'MySecret' -Secret 'MySecret'

        Creates a store called 'MySecret' in the vault with the secret.

        .EXAMPLE
        Set-Store -Name 'MySecret' -Secret 'MySecret' -Variables @{ 'Key' = 'Value' }

        Creates a store called 'MySecret' in the vault with the secret and variables.
    #>

    [OutputType([void])]
    [CmdletBinding(SupportsShouldProcess)]
    param (
        # The name of the store.
        [Parameter()]
        [Alias('Store', 'StoreName')]
        [string] $Name,

        # The secret of the store.
        [Parameter()]
        [object] $Secret = 'null',

        # The variables of the store.
        [Parameter()]
        [hashtable] $Variables
    )

    $param = @{
        Name  = $Name
        Vault = $script:Config.SecretVaultName
    }

    #Map secret based on type, to Secret or SecureStringSecret
    if ($Secret -is [System.Security.SecureString]) {
        $param['SecureStringSecret'] = $Secret
    } elseif ($Secret -is [string]) {
        $param['Secret'] = $Secret
    } else {
        throw 'Invalid secret type'
    }

    if ($Variables) {
        $param['Metadata'] = $Variables
    }
    if ($PSCmdlet.ShouldProcess('Set-Secret', $param)) {
        Set-Secret @param
    }
}

Write-Verbose "[$scriptName] - [functions] - [public] - [Store] - [Set-Store] - Done"
#endregion - From [functions] - [public] - [Store] - [Set-Store]

Write-Verbose "[$scriptName] - [functions] - [public] - [Store] - Done"
#endregion - From [functions] - [public] - [Store]

#region - From [functions] - [public] - [StoreConfig]
Write-Verbose "[$scriptName] - [functions] - [public] - [StoreConfig] - Processing folder"

#region - From [functions] - [public] - [StoreConfig] - [Get-StoreConfig]
Write-Verbose "[$scriptName] - [functions] - [public] - [StoreConfig] - [Get-StoreConfig] - Importing"

#Requires -Modules Microsoft.PowerShell.SecretManagement

function Get-StoreConfig {
    <#
        .SYNOPSIS
        Retrieve a named value from the store.

        .DESCRIPTION
        This function retrieves a named value from the specified store.
        If the value is a secret, it can be returned as plain text using the -AsPlainText switch.

        .EXAMPLE
        Get-StoreConfig -Name 'ApiBaseUri' -Store 'GitHub'

        Get the value of 'ApiBaseUri' config from the GitHub store.

        .EXAMPLE
        Get-StoreConfig -Name 'Api*' -Store 'GitHub'

        Get all configuration values from the GitHub store that match the wildcard pattern 'Api*'.
    #>

    [OutputType([object])]
    [CmdletBinding()]
    param (
        # The store to get the configuration from.
        [Parameter(Mandatory)]
        [string] $Store,

        # Name of a value to get.
        [Parameter(Mandatory)]
        [string] $Name,

        # Return the value as plain text if it is a secret.
        [Parameter()]
        [switch] $AsPlainText
    )

    Write-Verbose "Getting store configuration for store: [$Store]"
    $storeConfig = Get-Store -Name $Store -AsPlainText:$AsPlainText

    if ($null -eq $storeConfig) {
        Write-Verbose "No configuration found for store: [$Store]"
        return
    }

    $storeConfig.$Name
}

Write-Verbose "[$scriptName] - [functions] - [public] - [StoreConfig] - [Get-StoreConfig] - Done"
#endregion - From [functions] - [public] - [StoreConfig] - [Get-StoreConfig]
#region - From [functions] - [public] - [StoreConfig] - [Remove-StoreConfig]
Write-Verbose "[$scriptName] - [functions] - [public] - [StoreConfig] - [Remove-StoreConfig] - Importing"

filter Remove-StoreConfig {
    <#
        .SYNOPSIS
        Remove a named value from the store.

        .DESCRIPTION
        This function removes a named value from the specified store.
        It supports wildcard patterns for the name and can accept pipeline input from `Get-StoreConfig`.

        .EXAMPLE
        Remove-StoreConfig -Name 'APIBaseUri' -Store 'GitHub'

        Remove the APIBaseUri value from the 'GitHub' store.

        .EXAMPLE
        Get-StoreConfig -Store 'GitHub' | Remove-StoreConfig -Name 'API*'

        Remove all values starting with 'API' from the 'GitHub' store.

        .EXAMPLE
        Remove-StoreConfig -Name 'API*' -Store 'GitHub'

        Remove all values starting with 'API' from the 'GitHub' store.

        .EXAMPLE
        Get-StoreConfig -Store 'GitHub' | Where-Object { $_.Name -like 'API*' } | Remove-StoreConfig

        Remove all values starting with 'API' from the 'GitHub' store using pipeline input.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        # Name of a value to remove.
        [Parameter(
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [string] $Name,

        # The store to remove the value from.
        [Parameter(ValueFromPipelineByPropertyName)]
        [string] $Store
    )
    if ($PSCmdlet.ShouldProcess("Target", "Remove value [$Name] from store [$Store]")) {
        Set-StoreConfig -Name $Name -Value $null -Store $Store
    }
}

Write-Verbose "[$scriptName] - [functions] - [public] - [StoreConfig] - [Remove-StoreConfig] - Done"
#endregion - From [functions] - [public] - [StoreConfig] - [Remove-StoreConfig]
#region - From [functions] - [public] - [StoreConfig] - [Set-StoreConfig]
Write-Verbose "[$scriptName] - [functions] - [public] - [StoreConfig] - [Set-StoreConfig] - Importing"

#Requires -Modules Microsoft.PowerShell.SecretManagement

function Set-StoreConfig {
    <#
        .SYNOPSIS
        Sets a variable or secret in the store.

        .DESCRIPTION
        The `Set-StoreConfig` function sets a variable or secret in the specified store.
        To store a secret, set the name to 'Secret'.

        .EXAMPLE
        Set-StoreConfig -Name 'ApiBaseUri' -Value 'https://api.github.com' -Store 'GitHub'

        Sets a variable called 'ApiBaseUri' in the store called 'GitHub'.

        .EXAMPLE
        $secret = 'myAccessToken' | ConvertTo-SecureString -AsPlainText -Force
        Set-StoreConfig -Name 'Secret' -Value $secret -Store 'GitHub'

        Sets a secret called 'AccessToken' in the configuration store called 'GitHub'.

        .NOTES
        This function requires the Microsoft.PowerShell.SecretManagement module.
    #>

    [OutputType([void])]
    [CmdletBinding(SupportsShouldProcess)]
    param (
        # The name of the variable or secret to set.
        [Parameter(Mandatory)]
        [string] $Name,

        # The value to set for the specified name. This can be a plain text string or a secure string.
        [Parameter(Mandatory)]
        [AllowNull()]
        [AllowEmptyString()]
        [object] $Value,

        # The name of the store where the variable or secret will be set.
        [Parameter(Mandatory)]
        [string] $Store
    )

    $secretVault = Get-SecretVault | Where-Object { $_.Name -eq $script:Config.SecretVaultName }
    if (-not $secretVault) {
        Write-Error "Vault [$($script:Config.SecretVaultName)] not found"
        return
    }
    Write-Verbose "Retrieving secret info for store [$Store] from vault [$($secretVault.Name)]"
    $secretInfo = Get-SecretInfo -Name $Store -Vault $script:Config.SecretVaultName
    $secretValue = Get-Secret -Name $Store -Vault $script:Config.SecretVaultName
    if (-not $secretValue) {
        Write-Error "Store [$Store] not found"
        return
    }

    if ($PSCmdlet.ShouldProcess($Name, "Set value [$Value]")) {
        Write-Verbose "Processing [$Name] with value [$Value]"
        switch ($Name) {
            'Secret' {
                if ([string]::IsNullOrEmpty($Value)) {
                    Write-Verbose "Value is null or empty, setting to 'null'"
                    $Value = 'null'
                }
                if ($Value -is [SecureString]) {
                    Write-Verbose "Value is a SecureString, setting secret in vault [$($script:Config.SecretVaultName)]"
                    Set-Secret -Name $Store -SecureStringSecret $Value -Vault $script:Config.SecretVaultName
                } else {
                    Write-Verbose "Value is $($Value.GetType().FullName), setting secret in vault [$($script:Config.SecretVaultName)]"
                    Set-Secret -Name $Store -Value $Value -Vault $script:Config.SecretVaultName
                }
                break
            }
            'Name' {
                if ([string]::IsNullOrEmpty($Value)) {
                    Write-Error 'Name cannot be null or empty'
                    return
                }
                Set-Secret -Name $Value -SecureStringSecret $secretValue -Vault $Store -Metadata $secretInfo.Metadata
                $newSecretInfo = Get-SecretInfo -Name $Value -Vault $Store
                if ($newSecretInfo) {
                    Remove-Secret -Name $Name -Vault $Store
                } else {
                    Remove-Secret -Name $Value -Vault $Store
                }
                break
            }
            default {
                Write-Verbose 'Updating metadata'
                $metadata = ($secretInfo | Select-Object -ExpandProperty Metadata) + @{}
                if ([string]::IsNullOrEmpty($Value)) {
                    Write-Verbose " - Removing [$Name] from metadata"
                    $metadata.Remove($Name)
                } else {
                    Write-Verbose " - Setting [$Name] to [$Value] in metadata"
                    $metadata[$Name] = $Value
                }
                Write-Verbose "Updating secret info for store [$Store] in vault [$($script:Config.SecretVaultName)]"
                Set-SecretInfo -Name $Store -Metadata $metadata -Vault $script:Config.SecretVaultName
            }
        }
    }
}

Write-Verbose "[$scriptName] - [functions] - [public] - [StoreConfig] - [Set-StoreConfig] - Done"
#endregion - From [functions] - [public] - [StoreConfig] - [Set-StoreConfig]

Write-Verbose "[$scriptName] - [functions] - [public] - [StoreConfig] - Done"
#endregion - From [functions] - [public] - [StoreConfig]


Write-Verbose "[$scriptName] - [functions] - [public] - Done"
#endregion - From [functions] - [public]

#region - From [variables] - [private]
Write-Verbose "[$scriptName] - [variables] - [private] - Processing folder"

#region - From [variables] - [private] - [Config]
Write-Verbose "[$scriptName] - [variables] - [private] - [Config] - Importing"

$script:Config = @{
    Name            = 'PSModule.Store'                   # $script:Config.Name
    SecretVaultName = 'SecretStore'                      # $script:Config.SecretVaultName
    SecretVaultType = 'Microsoft.PowerShell.SecretStore' # $script:Config.SecretVaultType
}

Write-Verbose "[$scriptName] - [variables] - [private] - [Config] - Done"
#endregion - From [variables] - [private] - [Config]

Write-Verbose "[$scriptName] - [variables] - [private] - Done"
#endregion - From [variables] - [private]

#region - From [loader]
Write-Verbose "[$scriptName] - [loader] - Importing"

### This is the backend configuration for the functionality
try {
    $initStoreParams = @{
        Name = (Get-StoreConfig -Name SecretVaultName -Store $script:Config.Name) ?? $script:Config.SecretVaultName
        Type = (Get-StoreConfig -Name SecretVaultType -Store $script:Config.Name) ?? $script:Config.SecretVaultType
    }
    $vault = Initialize-SecretVault @initStoreParams
    $script:Config.SecretVaultName = $vault.Name
    $script:Config.SecretVaultType = $vault.ModuleName
} catch {
    Write-Error "Failed to initialize secret vault: $_"
    return
}

### This is the store config for this module
$storeParams = @{
    Name      = $script:Config.Name
    Variables = @{
        SecretVaultName = $script:Config.SecretVaultName
        SecretVaultType = $script:Config.SecretVaultType
    }
}
try {
    Set-Store @storeParams
} catch {
    Write-Error "Failed to set store parameters: $_"
}
Write-Verbose "[$scriptName] - [loader] - Done"
#endregion - From [loader]


$exports = @{
    Alias    = '*'
    Cmdlet   = ''
    Function = @(
        'Get-Store'
        'Remove-Store'
        'Set-Store'
        'Get-StoreConfig'
        'Remove-StoreConfig'
        'Set-StoreConfig'
    )
}
Export-ModuleMember @exports