From efa8b4f2c915df7543caa32b501af5b6ece39cfe Mon Sep 17 00:00:00 2001 From: Adam Listek Date: Wed, 6 May 2026 14:55:40 -0500 Subject: [PATCH] Add DVLS SecretManagement provider --- .../.gitignore | 18 + .../AGENTS.md | 97 ++ .../README.md | 277 ++++++ ...anagement.DevolutionsServer.Extension.psd1 | 29 + ...anagement.DevolutionsServer.Extension.psm1 | 834 ++++++++++++++++++ .../SecretManagement.DevolutionsServer.psd1 | 29 + ...cretManagement.DevolutionsServer.Tests.ps1 | 550 ++++++++++++ 7 files changed, 1834 insertions(+) create mode 100644 Modules/SecretManagement.DevolutionsServer/.gitignore create mode 100644 Modules/SecretManagement.DevolutionsServer/AGENTS.md create mode 100644 Modules/SecretManagement.DevolutionsServer/README.md create mode 100644 Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1 create mode 100644 Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psm1 create mode 100644 Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.psd1 create mode 100644 Modules/SecretManagement.DevolutionsServer/Tests/SecretManagement.DevolutionsServer.Tests.ps1 diff --git a/Modules/SecretManagement.DevolutionsServer/.gitignore b/Modules/SecretManagement.DevolutionsServer/.gitignore new file mode 100644 index 0000000..b3a861c --- /dev/null +++ b/Modules/SecretManagement.DevolutionsServer/.gitignore @@ -0,0 +1,18 @@ +# PowerShell build and test artifacts +TestResults/ +*.trx +*.coverage +*.nupkg + +# Local editor and OS files +.vscode/ +.idea/ +*.swp +Thumbs.db +Desktop.ini + +# Local secret/bootstrap files. PSU's .universal/vaults.ps1 can contain inline credentials. +.universal/vaults.ps1 +vaults.ps1.local +*.secret.ps1 +*.secrets.ps1 diff --git a/Modules/SecretManagement.DevolutionsServer/AGENTS.md b/Modules/SecretManagement.DevolutionsServer/AGENTS.md new file mode 100644 index 0000000..95bab85 --- /dev/null +++ b/Modules/SecretManagement.DevolutionsServer/AGENTS.md @@ -0,0 +1,97 @@ +# AGENTS.md + +## Purpose + +This repository contains a read-only PowerShell SecretManagement extension vault for Devolutions Server. It is primarily intended for PowerShell Universal credential retrieval. + +## Architecture + +- `SecretManagement.DevolutionsServer.psd1` is the parent module manifest used by SecretManagement registration. +- `SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1` is the nested extension manifest. +- `SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psm1` contains the consolidated implementation. +- `Tests/SecretManagement.DevolutionsServer.Tests.ps1` contains the Pester test suite. + +Keep the implementation consolidated unless read-only behavior grows enough that the `.psm1` becomes difficult to audit. + +## SecretManagement Constraints + +The nested extension module exports the required SecretManagement functions: + +- `Get-Secret` +- `Get-SecretInfo` +- `Set-Secret` +- `Remove-Secret` +- `Test-SecretVault` + +`Set-Secret` and `Remove-Secret` must remain unsupported and must throw read-only errors. Do not add write behavior without a design update and tests. + +## PowerShell Universal Deployment + +Document PSU deployment around the repository module path: + +```text +%ProgramData%\UniversalAutomation\Repository\Modules +``` + +Startup vault registration belongs in: + +```text +%ProgramData%\UniversalAutomation\Repository\.universal\vaults.ps1 +``` + +Do not document PSU secret variables as the bootstrap source for the DVLS `AppKey` and `AppSecret`. PSU runs `.universal\vaults.ps1` before variables are registered, so `$Secret:` values are not reliable for registering the DVLS vault itself. Inline credentials in `.universal\vaults.ps1` are allowed as the simple deployment mode, but the docs must call out ACL, Git-sync, least-privilege, and rotation precautions. + +Document PSU environment selection as part of validation and production use. The PSU integrated environment cannot use Run As credentials, so validation scripts and credential-backed jobs/APIs should use a non-integrated PowerShell 7.x environment configured under Settings > Environments. + +Document DVLS entry permissions as part of PSU setup. The application identity needs more than metadata visibility: `View password` is required for `Get-Secret`, and `View sensitive information` may be required depending on the credential fields. If `Get-SecretInfo` succeeds but `Get-Secret` reports a missing secret, lead troubleshooting toward DVLS entry permissions. + +## Security Rules + +Do not log app secrets, token IDs, passwords, SecureString contents, or raw DVLS sensitive payloads. + +Do not add verbose, debug, warning, output, or error messages that include secret material. + +Prefer `VaultId` in examples and production guidance. Cross-vault enumeration must remain explicit through `AllowVaultEnumeration`. + +Prefer granting `View password` only on the specific DVLS Credential entries PSU needs. + +## Future Mutable Operations + +The provider is read-only. Do not add DVLS write/delete behavior casually. If `Set-Secret` or `Remove-Secret` become mutable later, require a design update, `SupportsShouldProcess`, `-WhatIf`/`-Confirm` tests, explicit DVLS write-permission documentation, failure-mode coverage, and a fresh security review. Remove read-only PSScriptAnalyzer suppressions when those functions begin changing state. + +## Development Commands + +Run the full test suite: + +```powershell +Invoke-Pester -Path .\Tests\SecretManagement.DevolutionsServer.Tests.ps1 -Output Detailed +``` + +Validate module manifests: + +```powershell +Test-ModuleManifest .\SecretManagement.DevolutionsServer.psd1 +Test-ModuleManifest .\SecretManagement.DevolutionsServer.Extension\SecretManagement.DevolutionsServer.Extension.psd1 +``` + +Import the module from the repository: + +```powershell +Import-Module .\SecretManagement.DevolutionsServer.psd1 -Force +``` + +## Code Style + +- Target PowerShell 7.0 or later. Do not add newer 7.x-only syntax without raising the manifest requirement and tests. +- Keep `Set-StrictMode -Version Latest`. +- Keep exported functions compatible with Microsoft.PowerShell.SecretManagement signatures. +- Use structured helpers for REST calls and object property access. +- Use safe, specific errors. Avoid dumping response bodies. +- Add or update Pester tests before behavior changes. + +## Documentation + +When behavior changes, update: + +- `README.md` +- `AGENTS.md` diff --git a/Modules/SecretManagement.DevolutionsServer/README.md b/Modules/SecretManagement.DevolutionsServer/README.md new file mode 100644 index 0000000..8d3785b --- /dev/null +++ b/Modules/SecretManagement.DevolutionsServer/README.md @@ -0,0 +1,277 @@ +# SecretManagement.DevolutionsServer + +Read-only Microsoft.PowerShell.SecretManagement extension vault for Devolutions Server (DVLS), intended primarily for PowerShell Universal (PSU) credential retrieval. + +This provider returns DVLS Credential entries as `PSCredential` objects. It does not create, update, delete, or modify DVLS entries. + +## PowerShell Universal Quick Start + +### 1. Install Into Universal Modules + +Install the module into the PowerShell Universal repository module folder rather than a user profile or broad Windows module path. The default repository is `%ProgramData%\UniversalAutomation\Repository`, and PSU loads modules from `Repository\Modules`. + +Use this versioned layout: + +```text +%ProgramData%\UniversalAutomation\Repository\ + Modules\ + SecretManagement.DevolutionsServer\ + 1.0.0\ + SecretManagement.DevolutionsServer.psd1 + SecretManagement.DevolutionsServer.Extension\ + SecretManagement.DevolutionsServer.Extension.psd1 + SecretManagement.DevolutionsServer.Extension.psm1 +``` + +Install from this repository: + +```powershell +$repoModules = Join-Path $env:ProgramData 'UniversalAutomation\Repository\Modules' +$moduleRoot = Join-Path $repoModules 'SecretManagement.DevolutionsServer\1.0.0' + +New-Item -ItemType Directory -Force -Path $moduleRoot | Out-Null +Copy-Item .\SecretManagement.DevolutionsServer.psd1 -Destination $moduleRoot -Force +Copy-Item .\SecretManagement.DevolutionsServer.Extension -Destination $moduleRoot -Recurse -Force +``` + +This assumes `Microsoft.PowerShell.SecretManagement` is already available to the PSU PowerShell environment. If PSU cannot import SecretManagement, install it separately using your normal module-management process. + +After copying the module, restart the PowerShell Universal service or reload modules from Platform > Modules. The module should appear in Universal Modules because it is installed in the repository module path. + +### 2. Register The Vault At Startup + +Use PSU's startup vault file: + +```text +%ProgramData%\UniversalAutomation\Repository\.universal\vaults.ps1 +``` + +PSU runs `.universal\vaults.ps1` during system startup before variables are registered and vaults are located. Keep the registration idempotent so startup can run repeatedly. + +Example `.universal\vaults.ps1` using inline credentials: + +```powershell +Import-Module Microsoft.PowerShell.SecretManagement -ErrorAction Stop +Import-Module SecretManagement.DevolutionsServer -RequiredVersion 1.0.0 -ErrorAction Stop + +$vaultName = 'DVLS' +$existingVault = Get-SecretVault -Name $vaultName -ErrorAction SilentlyContinue + +if ($existingVault) { + Unregister-SecretVault -Name $vaultName +} + +Register-SecretVault ` + -Name $vaultName ` + -ModuleName 'SecretManagement.DevolutionsServer' ` + -VaultParameters @{ + ServerUrl = 'https://dvls.example.com' + AppKey = 'paste-application-key-here' + AppSecret = 'paste-application-secret-here' + VaultId = '00000000-0000-0000-0000-000000000000' + } ` + -AllowClobber +``` + +Inline credentials are the simplest bootstrap option. Treat `.universal\vaults.ps1` as sensitive: + +- Do not Git-sync `.universal\vaults.ps1` unless the remote repository is approved to hold secrets. +- Restrict NTFS permissions on `.universal\vaults.ps1` and the repository folder to the PSU service account and administrators. +- Use a dedicated DVLS application key with access only to the required vault. +- Rotate the DVLS application secret when staff or repository access changes. + +The DVLS identity behind the application key must also have entry-level permission to retrieve credential values. Listing a Credential entry is not enough. For each credential that PSU needs, grant the identity: + +- `View` +- `View password` +- `View sensitive information`, when the credential or endpoint requires sensitive fields beyond the password + +In DVLS, check the Credential entry's Security > Permissions page. `Get-SecretInfo` can succeed when only metadata is visible, while `Get-Secret` can still fail if `View password` is denied. + +### 3. Restart PSU + +Restart the PowerShell Universal service so PSU reloads repository modules and runs `.universal\vaults.ps1`. + +### 4. Validate From PSU + +Create a PSU test script so validation runs inside the same environment PSU will use. + +In the PSU admin console: + +1. Go to Automation > Scripts. +2. Create a new script, for example `Test-DVLSSecretVault.ps1`. +3. Select the PowerShell 7.x environment that your jobs/APIs will use. +4. Add this script body: + +```powershell +Import-Module Microsoft.PowerShell.SecretManagement -ErrorAction Stop +Import-Module SecretManagement.DevolutionsServer -RequiredVersion 1.0.0 -ErrorAction Stop + +Get-Module SecretManagement.DevolutionsServer* -ListAvailable | + Select-Object Name, Version, Path + +Get-SecretVault -Name 'DVLS' +Test-SecretVault -Name 'DVLS' +Get-SecretInfo -Vault 'DVLS' +``` + +5. Run the script from PSU and review the job output. + +Do not use the PSU integrated environment for this validation or for production scripts that need Run As credentials. Integrated environments cannot use Run As credentials, so a script assigned to that environment will not behave like a credential-backed PSU workload. Create or select a non-integrated PowerShell 7.x environment under Settings > Environments, then use that environment for the validation script and for the jobs/APIs that consume the vault. + +`Test-SecretVault -Name 'DVLS'` should return `True`. `Get-SecretInfo` should list readable DVLS Credential entries. + +Also validate an actual credential retrieval: + +```powershell +Get-Secret -Name 'DomainAdmin' -Vault 'DVLS' +``` + +If `Get-SecretInfo` lists the secret but `Get-Secret` reports that the secret was not found, check the DVLS entry permissions. The application identity likely has metadata access but does not have `View password` on that Credential entry. + +### 5. Use In PSU Variables + +After the vault validates, use Platform > Variables > Import Secret to import existing DVLS secrets. PSU will resolve those secrets through SecretManagement when scripts, APIs, or jobs use them. + +## Requirements + +- PowerShell 7.0 or later. +- Microsoft.PowerShell.SecretManagement available in the PSU environment. +- A Devolutions Server URL reachable from the PSU host. +- A DVLS application key and application secret with least-privilege access to the target vault. +- DVLS Credential entries. Other entry types are ignored. +- DVLS entry permissions that allow `View password` and, when required, `View sensitive information`. + +## Bootstrap Credentials + +Do not use PSU secret variables for the DVLS `AppKey` and `AppSecret` that register this same vault. PSU runs `.universal\vaults.ps1` before variables are registered, so `$Secret:` values are not a reliable bootstrap source for vault registration. + +Practical options, from safest to simplest: + +- Use protected machine or service environment variables such as `DVLS_APP_KEY` and `DVLS_APP_SECRET`. +- Use a protected local bootstrap file outside the repository ACLed to the PSU service account. +- Register the DVLS vault once as the PSU service account and omit startup re-registration. +- Use inline credentials in `.universal\vaults.ps1` and treat that file as sensitive. + +For this project, inline credentials are documented because they are operationally simple and avoid a vault-to-vault bootstrap chain. + +## Vault Parameters + +| Name | Required | Default | Description | +| --- | --- | --- | --- | +| `ServerUrl` | Yes | None | HTTPS DVLS base URL. Query strings and fragments are rejected. | +| `AppKey` | Yes | None | DVLS application key. | +| `AppSecret` | Yes | None | DVLS application secret. | +| `VaultId` | Recommended | None | DVLS vault ID. Required unless `AllowVaultEnumeration` is true. | +| `AllowVaultEnumeration` | No | `false` | Allows searching all accessible vaults when `VaultId` is not supplied. Prefer `VaultId` in production. | +| `RequestTimeoutSeconds` | No | `30` | REST timeout. Valid range: 1-300. | +| `PageSize` | No | `100` | Entry list page size. Valid range: 1-1000. | + +## Usage + +Retrieve a DVLS Credential entry: + +```powershell +$credential = Get-Secret -Name 'SqlProd' -Vault 'DVLS' +$credential.UserName +``` + +List available credential names: + +```powershell +Get-SecretInfo -Vault 'DVLS' +Get-SecretInfo -Vault 'DVLS' -Name '*Prod*' +``` + +Unsupported operations fail with read-only errors: + +```powershell +Set-Secret -Name 'SqlProd' -Vault 'DVLS' -Secret $credential +Remove-Secret -Name 'SqlProd' -Vault 'DVLS' +``` + +## Local Development + +Register from this repository for local testing: + +```powershell +Import-Module Microsoft.PowerShell.SecretManagement -ErrorAction Stop + +Register-SecretVault ` + -Name 'DVLS-Dev' ` + -ModuleName (Resolve-Path .\SecretManagement.DevolutionsServer.psd1).Path ` + -VaultParameters @{ + ServerUrl = 'https://dvls.example.com' + AppKey = 'your-application-key' + AppSecret = 'your-application-secret' + VaultId = '00000000-0000-0000-0000-000000000000' + } ` + -AllowClobber +``` + +## Security + +- HTTPS is required. +- The module never writes app secrets, token IDs, passwords, or returned credential payloads to verbose, warning, debug, output, or error streams. +- Use `VaultId` in production to avoid broad cross-vault searches. +- Rotate DVLS application secrets regularly. +- Use least privilege: the DVLS application identity should only access the vault and entries required by PSU. +- Grant `View password` only on the specific DVLS Credential entries PSU needs. +- Treat SecretManagement vault registration data as sensitive operational configuration. + +## Future Mutable Operations + +This module is intentionally read-only. If `Set-Secret` or `Remove-Secret` are implemented later, treat that as a new design and security review: + +- Add `SupportsShouldProcess` and tests for `-WhatIf` and `-Confirm`. +- Replace the current read-only implementations and analyzer suppressions with explicit DVLS write/delete logic. +- Document the required DVLS create, edit, delete, and password-management permissions separately from read permissions. +- Add tests for create, update, delete, API failure, partial failure, and secret-redaction behavior. +- Keep DVLS as the source of truth for audit and lifecycle policy unless the architecture is explicitly changed. + +## Troubleshooting + +`Test-SecretVault -Name DVLS` returns false: + +- Confirm the test ran inside PSU, not only in an administrator shell. +- Confirm the PSU script uses the same PowerShell 7 environment as the workload. +- Confirm `ServerUrl` is HTTPS and reachable from the PSU host. +- Confirm the DVLS application key and secret are valid. +- Confirm the application identity can read the configured `VaultId`. + +`Get-Secret` returns nothing: + +- Confirm the DVLS entry is type Credential. +- Confirm the SecretManagement name exactly matches the DVLS entry name, or pass the entry GUID. +- Confirm the application identity has `View password` for the entry. +- Confirm the application identity has `View sensitive information` if the entry requires sensitive fields beyond the password. +- If `Get-SecretInfo` can see the entry but `Get-Secret` cannot retrieve it, treat that as a DVLS credential-value permission problem first. + +PowerShell Universal cannot see the vault: + +- Confirm `.universal\vaults.ps1` ran at service startup. +- Confirm the module appears in Universal Modules. +- Confirm `Microsoft.PowerShell.SecretManagement` is available to the PSU environment. +- Confirm the script is assigned to a non-integrated PowerShell 7.x environment from Settings > Environments when Run As credentials are required. +- Restart the PSU service after module installation or vault registration changes. + +## Development + +Run tests: + +```powershell +Invoke-Pester -Path .\Tests\SecretManagement.DevolutionsServer.Tests.ps1 -Output Detailed +``` + +Validate manifests: + +```powershell +Test-ModuleManifest .\SecretManagement.DevolutionsServer.psd1 +Test-ModuleManifest .\SecretManagement.DevolutionsServer.Extension\SecretManagement.DevolutionsServer.Extension.psd1 +``` + +Import from the repository: + +```powershell +Import-Module .\SecretManagement.DevolutionsServer.psd1 -Force +``` diff --git a/Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1 b/Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1 new file mode 100644 index 0000000..7b0754f --- /dev/null +++ b/Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1 @@ -0,0 +1,29 @@ +# SecretManagement extension manifest. +# +# Microsoft.PowerShell.SecretManagement loads this nested module when the DVLS +# vault is registered. The exported function names are the contract required by +# SecretManagement extension vaults. +@{ + ModuleVersion = '1.0.0' + GUID = '41a4e09c-ba9f-43bb-99ad-af48e6c1657c' + Author = 'Devolutions' + CompanyName = 'Devolutions' + Copyright = '(c) 2026 Devolutions. All rights reserved.' + Description = 'Read-only SecretManagement.DevolutionsServer extension implementation' + PowerShellVersion = '7.0' + RequiredModules = @('Microsoft.PowerShell.SecretManagement') + + RootModule = 'SecretManagement.DevolutionsServer.Extension.psm1' + + FunctionsToExport = @( + 'Get-Secret' + 'Get-SecretInfo' + 'Set-Secret' + 'Remove-Secret' + 'Test-SecretVault' + ) + + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() +} diff --git a/Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psm1 b/Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psm1 new file mode 100644 index 0000000..e8ef8d9 --- /dev/null +++ b/Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psm1 @@ -0,0 +1,834 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS +Read-only Microsoft.PowerShell.SecretManagement extension vault for Devolutions Server. + +.DESCRIPTION +This module is loaded by SecretManagement after a DVLS vault is registered. It +authenticates to Devolutions Server with application credentials, enumerates +Credential entries, and returns matching entries as PSCredential objects. + +The provider is intentionally read-only. Set-Secret and Remove-Secret reject +write operations because DVLS remains the system of record for credential +lifecycle management. + +Future mutable operations: if this module later implements Set-Secret or +Remove-Secret against DVLS write APIs, treat that as a design change. Remove +read-only suppressions, add SupportsShouldProcess with WhatIf/Confirm tests, +document required DVLS write permissions, and add coverage for create, update, +delete, rollback/failure, and audit behavior before release. +#> +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module Microsoft.PowerShell.SecretManagement -ErrorAction Stop + +$script:DefaultRequestTimeoutSeconds = 30 +$script:DefaultPageSize = 100 + +#region Configuration and validation + +<# +.SYNOPSIS +Parses optional SecretManagement boolean vault parameters safely. + +.DESCRIPTION +PowerShell treats every non-empty string as true when cast to Boolean, including +the string 'false'. This parser accepts Boolean values and the strings 'true' or +'false', defaults missing values to false, and fails closed for anything else. + +.PARAMETER Value +The untyped value received through SecretManagement VaultParameters. +#> +Function ConvertTo-DVLSBoolean { + [CmdletBinding()] + Param( + [Object]$Value + ) + Process { + If ($Null -EQ $Value) { + Return $False + } + + If ($Value -IS [Bool]) { + Return $Value + } + + $parsed = $False + If ([Bool]::TryParse([String]$Value, [ref]$parsed)) { + Return $parsed + } + + Throw "VaultParameters key 'AllowVaultEnumeration' must be true or false." + } +} + +<# +.SYNOPSIS +Validates and normalizes the DVLS server URL. + +.PARAMETER AdditionalParameters +The SecretManagement VaultParameters hashtable supplied during vault +registration. +#> +Function Resolve-DVLSServerUrl { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [Hashtable]$AdditionalParameters + ) + Process { + $rawServerUrl = $AdditionalParameters['ServerUrl'] + If (-NOT $rawServerUrl -OR [String]::IsNullOrWhiteSpace([String]$rawServerUrl)) { + Throw "VaultParameters is missing required key 'ServerUrl'." + } + + $serverUri = $Null + If (-NOT [System.Uri]::TryCreate(([String]$rawServerUrl).Trim(), [System.UriKind]::Absolute, [ref]$serverUri)) { + Throw 'ServerUrl must be a valid absolute URI.' + } + + If ($serverUri.Scheme -NE 'https') { + Throw 'ServerUrl must use HTTPS.' + } + + If ($serverUri.Query -OR $serverUri.Fragment) { + Throw 'ServerUrl must not include a query string or fragment.' + } + + Return $serverUri.AbsoluteUri.TrimEnd('/') + } +} + +<# +.SYNOPSIS +Configuration parser for SecretManagement VaultParameters. + +.DESCRIPTION +Validates required DVLS connection settings and normalizes optional runtime +controls. SecretManagement passes VaultParameters as an untyped hashtable, so +this function is the boundary where deployment configuration becomes a typed +internal object. + +.PARAMETER AdditionalParameters +The SecretManagement VaultParameters hashtable. Required keys are ServerUrl, +AppKey, and AppSecret. VaultId is recommended and required unless +AllowVaultEnumeration is explicitly true. +#> +Function Get-DVLSVaultConfiguration { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [Hashtable]$AdditionalParameters + ) + Process { + ForEach ($key in 'ServerUrl', 'AppKey', 'AppSecret') { + If (-NOT $AdditionalParameters[$key] -OR [String]::IsNullOrWhiteSpace([String]$AdditionalParameters[$key])) { + Throw "VaultParameters is missing required key '$key'." + } + } + + $allowVaultEnumeration = ConvertTo-DVLSBoolean -Value $AdditionalParameters['AllowVaultEnumeration'] + $vaultId = [String]($AdditionalParameters['VaultId'] ?? '') + $requestTimeoutSeconds = $script:DefaultRequestTimeoutSeconds + $pageSize = $script:DefaultPageSize + + If ([String]::IsNullOrWhiteSpace($vaultId)) { + If (-NOT $allowVaultEnumeration) { + Throw "VaultParameters is missing required key 'VaultId'. Set AllowVaultEnumeration=true only if cross-vault enumeration is explicitly intended." + } + + $vaultId = '' + } + + If ($Null -NE $AdditionalParameters['RequestTimeoutSeconds'] -AND -NOT [String]::IsNullOrWhiteSpace([String]$AdditionalParameters['RequestTimeoutSeconds'])) { + $parsedRequestTimeoutSeconds = 0 + If (-NOT [Int]::TryParse([String]$AdditionalParameters['RequestTimeoutSeconds'], [ref]$parsedRequestTimeoutSeconds)) { + Throw "VaultParameters key 'RequestTimeoutSeconds' must be a positive integer." + } + + If ($parsedRequestTimeoutSeconds -LT 1 -OR $parsedRequestTimeoutSeconds -GT 300) { + Throw "VaultParameters key 'RequestTimeoutSeconds' must be between 1 and 300." + } + + $requestTimeoutSeconds = $parsedRequestTimeoutSeconds + } + + If ($Null -NE $AdditionalParameters['PageSize'] -AND -NOT [String]::IsNullOrWhiteSpace([String]$AdditionalParameters['PageSize'])) { + $parsedPageSize = 0 + If (-NOT [Int]::TryParse([String]$AdditionalParameters['PageSize'], [ref]$parsedPageSize)) { + Throw "VaultParameters key 'PageSize' must be a positive integer." + } + + If ($parsedPageSize -LT 1 -OR $parsedPageSize -GT 1000) { + Throw "VaultParameters key 'PageSize' must be between 1 and 1000." + } + + $pageSize = $parsedPageSize + } + + Return [PSCustomObject]@{ + ServerUrl = Resolve-DVLSServerUrl -AdditionalParameters $AdditionalParameters + AppKey = [String]$AdditionalParameters['AppKey'] + AppSecret = [String]$AdditionalParameters['AppSecret'] + VaultId = $vaultId + AllowVaultEnumeration = $allowVaultEnumeration + RequestTimeoutSeconds = $requestTimeoutSeconds + PageSize = $pageSize + } + } +} + +Function Get-DVLSReadOnlyErrorMessage { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [String]$Operation + ) + Process { + Return "SecretManagement.DevolutionsServer is read-only. '$Operation' is not supported; manage DVLS Credential entries in Devolutions Server." + } +} + +Function Get-DVLSSafeErrorMessage { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [String]$Action, + [System.Exception]$Exception + ) + Process { + If ($Null -EQ $Exception -OR [String]::IsNullOrWhiteSpace($Exception.Message)) { + Return $Action + } + + Return '{0}: {1}' -f $Action, $Exception.Message + } +} + +#endregion + +#region URI and HTTP helpers + +Function Join-DVLSApiUri { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [String]$ServerUrl, + [Parameter(Mandatory)] + [String[]]$PathSegments, + [Hashtable]$Query + ) + Process { + $encodedPath = ($PathSegments | ForEach-Object { [System.Uri]::EscapeDataString([String]$_) }) -join '/' + $uriBuilder = [System.UriBuilder]::new("$ServerUrl/$encodedPath") + + If ($Query) { + $uriBuilder.Query = (($Query.GetEnumerator() | ForEach-Object { + '{0}={1}' -f [System.Uri]::EscapeDataString([String]$_.Key), [System.Uri]::EscapeDataString([String]$_.Value) + }) -join '&') + } + + Return $uriBuilder.Uri.AbsoluteUri + } +} + +Function Get-DVLSRequestOption { + [CmdletBinding()] + Param( + [Object]$Configuration + ) + Process { + $timeoutSeconds = $script:DefaultRequestTimeoutSeconds + If ($Null -NE $Configuration) { + $timeoutProperty = $Configuration.PSObject.Properties | Where-Object { $_.Name -EQ 'RequestTimeoutSeconds' } | Select-Object -First 1 + If ($Null -NE $timeoutProperty -AND $Null -NE $timeoutProperty.Value) { + $timeoutSeconds = [Int]$timeoutProperty.Value + } + } + + Return @{ + ContentType = 'application/json' + SkipHttpErrorCheck = $True + TimeoutSec = $timeoutSeconds + HttpVersion = '2.0' + } + } +} + +Function Invoke-DVLSRestMethod { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [ValidateSet('Get', 'Post')] + [String]$Method, + [Parameter(Mandatory)] + [String]$Uri, + [String]$Token, + [Object]$Body, + [Object]$Configuration + ) + Process { + $statusCode = 0 + $request = @{ + Method = $Method + Uri = $Uri + StatusCodeVariable = 'statusCode' + } + + If ($Token) { + $request['Headers'] = @{ tokenId = $Token } + } + + If ($Null -NE $Body) { + $request['Body'] = ($Body -IS [String]) ? $Body : ($Body | ConvertTo-Json -Compress -Depth 10) + } + + $requestOptions = Get-DVLSRequestOption -Configuration $Configuration + $response = Invoke-RestMethod @request @requestOptions + + # Pester mocks do not populate -StatusCodeVariable, so tests may provide StatusCode explicitly. + $statusCodeProperty = $Null + If ($statusCode -EQ 0 -AND $Null -NE $response) { + $statusCodeProperty = $response.PSObject.Properties | Where-Object { $_.Name -EQ 'StatusCode' } | Select-Object -First 1 + } + + If ($Null -NE $statusCodeProperty) { + $statusCode = [Int]$statusCodeProperty.Value + } + + If ($statusCode -GE 400) { + Throw "DVLS API error HTTP $statusCode during $Method request." + } + + Return $response + } +} + +#endregion + +#region DVLS session + +Function Connect-DVLSSession { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [Object]$Configuration + ) + Process { + $response = Invoke-DVLSRestMethod ` + -Method Post ` + -Uri (Join-DVLSApiUri -ServerUrl $Configuration.ServerUrl -PathSegments @('api', 'v1', 'login')) ` + -Body @{ appKey = $Configuration.AppKey; appSecret = $Configuration.AppSecret } ` + -Configuration $Configuration + + $tokenId = Get-DVLSObjectProperty -InputObject $response -Name @('tokenId', 'TokenId') + If (-NOT $tokenId) { + Throw 'Devolutions Server authentication failed.' + } + + Return [PSCustomObject]@{ + TokenId = [String]$tokenId + ServerUrl = $Configuration.ServerUrl + Configuration = $Configuration + } + } +} + +Function Close-DVLSSession { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [Object]$Session + ) + Process { + Try { + [Void](Invoke-DVLSRestMethod ` + -Method Post ` + -Uri (Join-DVLSApiUri -ServerUrl $Session.ServerUrl -PathSegments @('api', 'v1', 'logout')) ` + -Token $Session.TokenId ` + -Configuration $Session.Configuration) + } Catch { + Write-Warning (Get-DVLSSafeErrorMessage -Action 'Devolutions Server logout warning' -Exception $_.Exception) + } + } +} + +#endregion + +#region Vault and entry lookup + +Function Get-DVLSObjectProperty { + [CmdletBinding()] + Param( + [Object]$InputObject, + [Parameter(Mandatory)] + [String[]]$Name + ) + Process { + If ($Null -EQ $InputObject) { + Return $Null + } + + ForEach ($candidateName in $Name) { + $property = $InputObject.PSObject.Properties | Where-Object { $_.Name -EQ $candidateName } | Select-Object -First 1 + If ($Null -NE $property) { + Return $property.Value + } + } + + Return $Null + } +} + +Function Get-DVLSResponseData { + [CmdletBinding()] + Param( + [Object]$Response + ) + Process { + $data = Get-DVLSObjectProperty -InputObject $Response -Name @('data', 'Data') + If ($Null -NE $data) { + Return $data + } + + Return $Response + } +} + +Function Get-DVLSVaultId { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [Object]$Session + ) + Process { + If ($Session.Configuration.VaultId) { + Return @($Session.Configuration.VaultId) + } + + $response = Invoke-DVLSRestMethod ` + -Method Get ` + -Uri (Join-DVLSApiUri -ServerUrl $Session.ServerUrl -PathSegments @('api', 'v1', 'vault')) ` + -Token $Session.TokenId ` + -Configuration $Session.Configuration + + $vaults = $response -IS [Array] ? $response : @(Get-DVLSResponseData -Response $response) + Return @($vaults | ForEach-Object { Get-DVLSObjectProperty -InputObject $_ -Name @('id', 'Id') } | Where-Object { $_ }) + } +} + +Function Get-DVLSCredentialEntry { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [Object]$Session, + [Parameter(Mandatory)] + [String]$VaultId + ) + Process { + $entries = [System.Collections.Generic.List[Object]]::new() + $pageNumber = 1 + + Do { + $response = Invoke-DVLSRestMethod ` + -Method Get ` + -Uri (Join-DVLSApiUri -ServerUrl $Session.ServerUrl -PathSegments @('api', 'v1', 'vault', $VaultId, 'entry') -Query @{ + pageNumber = $pageNumber + pageSize = $Session.Configuration.PageSize + }) ` + -Token $Session.TokenId ` + -Configuration $Session.Configuration + + $data = @(Get-DVLSResponseData -Response $response) + ForEach ($entry in $data) { + If ((Get-DVLSObjectProperty -InputObject $entry -Name @('type', 'Type')) -EQ 'Credential') { + $entries.Add($entry) + } + } + + $currentPage = Get-DVLSObjectProperty -InputObject $response -Name @('currentPage', 'CurrentPage') + $totalPage = Get-DVLSObjectProperty -InputObject $response -Name @('totalPage', 'TotalPage') + $currentPage = $currentPage ?? 1 + $totalPage = $totalPage ?? 1 + $pageNumber++ + } While ($currentPage -LT $totalPage) + + Return $entries.ToArray() + } +} + +Function Get-DVLSCredentialEntryDetail { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [Object]$Session, + [Parameter(Mandatory)] + [String]$VaultId, + [Parameter(Mandatory)] + [String]$EntryId + ) + Process { + $response = Invoke-DVLSRestMethod ` + -Method Get ` + -Uri (Join-DVLSApiUri -ServerUrl $Session.ServerUrl -PathSegments @('api', 'v1', 'vault', $VaultId, 'entry', $EntryId) -Query @{ + includePasswords = 'true' + includeSensitiveData = 'true' + }) ` + -Token $Session.TokenId ` + -Configuration $Session.Configuration + + Return (Get-DVLSResponseData -Response $response) + } +} + +Function Find-DVLSCredentialEntry { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [Object]$Session, + [Parameter(Mandatory)] + [String]$Name + ) + Process { + $vaultIds = @(Get-DVLSVaultId -Session $Session) + + ForEach ($vaultId in $vaultIds) { + $match = Get-DVLSCredentialEntry -Session $Session -VaultId $vaultId | + Where-Object { (Get-DVLSObjectProperty -InputObject $_ -Name @('name', 'Name')) -EQ $Name } | + Select-Object -First 1 + + If ($match) { + Return [PSCustomObject]@{ + Entry = $match + VaultId = $vaultId + Detail = $Null + } + } + } + + $parsedGuid = [System.Guid]::Empty + If (-NOT [System.Guid]::TryParse($Name, [ref]$parsedGuid)) { + Return $Null + } + + ForEach ($vaultId in $vaultIds) { + Try { + $detail = Get-DVLSCredentialEntryDetail -Session $Session -VaultId $vaultId -EntryId $Name + $entryType = Get-DVLSObjectProperty -InputObject $detail -Name @('type', 'Type') + If ($entryType -AND $entryType -NE 'Credential') { + Continue + } + + Return [PSCustomObject]@{ + Entry = $detail + VaultId = $vaultId + Detail = $detail + } + } Catch { + Continue + } + } + + Return $Null + } +} + +#endregion + +#region SecretManagement output conversion + +Function ConvertTo-DVLSPsCredential { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', + '', + Justification = 'DVLS returns the credential value as plaintext and SecretManagement requires a PSCredential. The value is converted immediately and is not logged or persisted.' + )] + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [Object]$EntryData + ) + Process { + $username = [String](Get-DVLSObjectProperty -InputObject $EntryData -Name @('username', 'Username')) + $password = [String](Get-DVLSObjectProperty -InputObject $EntryData -Name @('password', 'Password')) + $domain = [String](Get-DVLSObjectProperty -InputObject $EntryData -Name @('domain', 'Domain')) + + If ($domain -AND $username -NOTLIKE '*@*' -AND $username -NOTLIKE '*\*') { + $username = "$username@$domain" + } + + Return [System.Management.Automation.PSCredential]::new( + $username, + (ConvertTo-SecureString -String $password -AsPlainText -Force) + ) + } +} + +Function ConvertTo-DVLSSecretInformation { + [CmdletBinding()] + Param( + [Parameter(Mandatory)] + [String]$Name, + [Parameter(Mandatory)] + [String]$VaultName + ) + Process { + Return [Microsoft.PowerShell.SecretManagement.SecretInformation]::new( + $Name, + [Microsoft.PowerShell.SecretManagement.SecretType]::PSCredential, + $VaultName + ) + } +} + +#endregion + +#region SecretManagement required functions + +<# +.SYNOPSIS +Retrieves a DVLS Credential entry as a PSCredential. + +.DESCRIPTION +Called by Microsoft.PowerShell.SecretManagement. This provider is read-only and only returns DVLS entries of type Credential. + +.PARAMETER Name +SecretManagement name to resolve. This can be the DVLS Credential entry name or, +as a fallback, the DVLS entry GUID. + +.PARAMETER VaultName +The SecretManagement vault name supplied by the SecretManagement engine. + +.PARAMETER AdditionalParameters +VaultParameters supplied when the vault was registered. The provider expects +ServerUrl, AppKey, AppSecret, and preferably VaultId. +#> +Function Get-Secret { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', + 'VaultName', + Justification = 'VaultName is required by the SecretManagement extension contract; DVLS lookup uses the registered VaultParameters instead.' + )] + [CmdletBinding()] + Param( + [String]$Name, + [String]$VaultName, + [Hashtable]$AdditionalParameters + ) + Process { + $configuration = Get-DVLSVaultConfiguration -AdditionalParameters $AdditionalParameters + $session = Connect-DVLSSession -Configuration $configuration + + Try { + $match = Find-DVLSCredentialEntry -Session $session -Name $Name + If (-NOT $match) { + Return $Null + } + + $entryDetail = $match.Detail + If (-NOT $entryDetail) { + $entryId = Get-DVLSObjectProperty -InputObject $match.Entry -Name @('id', 'Id') + If (-NOT $entryId) { + Return $Null + } + + $entryDetail = Get-DVLSCredentialEntryDetail -Session $session -VaultId $match.VaultId -EntryId $entryId + } + + Return (ConvertTo-DVLSPsCredential -EntryData $entryDetail) + } Finally { + Close-DVLSSession -Session $session + } + } +} + +<# +.SYNOPSIS +Lists DVLS Credential entries available through the registered vault. + +.DESCRIPTION +Called by Microsoft.PowerShell.SecretManagement. Results are metadata only; secret values are never returned by this function. + +.PARAMETER Filter +SecretManagement wildcard filter applied to DVLS Credential entry names. + +.PARAMETER VaultName +The SecretManagement vault name supplied by the SecretManagement engine. + +.PARAMETER AdditionalParameters +VaultParameters supplied when the vault was registered. The provider expects +ServerUrl, AppKey, AppSecret, and preferably VaultId. +#> +Function Get-SecretInfo { + [CmdletBinding()] + Param( + [String]$Filter, + [String]$VaultName, + [Hashtable]$AdditionalParameters + ) + Process { + $configuration = Get-DVLSVaultConfiguration -AdditionalParameters $AdditionalParameters + $session = Connect-DVLSSession -Configuration $configuration + $effectiveFilter = $Filter ? $Filter : '*' + + Try { + $vaultIds = @(Get-DVLSVaultId -Session $session) + ForEach ($vaultId in $vaultIds) { + Get-DVLSCredentialEntry -Session $session -VaultId $vaultId | + ForEach-Object { + $entryName = [String](Get-DVLSObjectProperty -InputObject $_ -Name @('name', 'Name')) + If ([String]::IsNullOrWhiteSpace($entryName) -OR $entryName -NotLike $effectiveFilter) { + Return + } + + ConvertTo-DVLSSecretInformation -Name $entryName -VaultName $VaultName + } + } + } Finally { + Close-DVLSSession -Session $session + } + } +} + +<# +.SYNOPSIS +Rejects write attempts because this DVLS provider is read-only. + +.DESCRIPTION +DVLS remains the system of record for credential lifecycle management. This +function exists only because SecretManagement extension vaults must expose it. + +.PARAMETER Name +Ignored. Included for SecretManagement extension compatibility. + +.PARAMETER Secret +Ignored. Included for SecretManagement extension compatibility. + +.PARAMETER VaultName +Ignored. Included for SecretManagement extension compatibility. + +.PARAMETER Metadata +Ignored. Included for SecretManagement extension compatibility. + +.PARAMETER AdditionalParameters +Ignored. Included for SecretManagement extension compatibility. +#> +Function Set-Secret { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', + '', + Justification = 'SecretManagement requires Set-Secret, but this read-only provider always throws and performs no state change.' + )] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', + '', + Justification = 'These parameters are required by the SecretManagement extension contract and remain unused while the provider is read-only.' + )] + [CmdletBinding()] + Param( + [String]$Name, + [Object]$Secret, + [String]$VaultName, + [Hashtable]$Metadata, + [Hashtable]$AdditionalParameters + ) + Process { + Throw (Get-DVLSReadOnlyErrorMessage -Operation 'Set-Secret') + } +} + +<# +.SYNOPSIS +Rejects delete attempts because this DVLS provider is read-only. + +.DESCRIPTION +DVLS remains the system of record for credential lifecycle management. This +function exists only because SecretManagement extension vaults must expose it. + +Future mutable operations must remove the read-only analyzer suppression, add +SupportsShouldProcess, and implement explicit DVLS write/delete permission and +test coverage before this function changes state. + +.PARAMETER Name +Ignored. Included for SecretManagement extension compatibility. + +.PARAMETER VaultName +Ignored. Included for SecretManagement extension compatibility. + +.PARAMETER AdditionalParameters +Ignored. Included for SecretManagement extension compatibility. +#> +Function Remove-Secret { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', + '', + Justification = 'SecretManagement requires Remove-Secret, but this read-only provider always throws and performs no state change.' + )] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', + '', + Justification = 'These parameters are required by the SecretManagement extension contract and remain unused while the provider is read-only.' + )] + [CmdletBinding()] + Param( + [String]$Name, + [String]$VaultName, + [Hashtable]$AdditionalParameters + ) + Process { + Throw (Get-DVLSReadOnlyErrorMessage -Operation 'Remove-Secret') + } +} + +<# +.SYNOPSIS +Validates DVLS connectivity for the registered SecretManagement vault. + +.DESCRIPTION +Returns exactly one Boolean to the pipeline. Diagnostic details are written only to the error stream. + +.PARAMETER VaultName +The SecretManagement vault name supplied by the SecretManagement engine. + +.PARAMETER AdditionalParameters +VaultParameters supplied when the vault was registered. The provider expects +ServerUrl, AppKey, AppSecret, and preferably VaultId. +#> +Function Test-SecretVault { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', + 'VaultName', + Justification = 'VaultName is required by the SecretManagement extension contract; validation uses the registered VaultParameters instead.' + )] + [CmdletBinding()] + Param( + [String]$VaultName, + [Hashtable]$AdditionalParameters + ) + Process { + $session = $Null + + Try { + $configuration = Get-DVLSVaultConfiguration -AdditionalParameters $AdditionalParameters + $session = Connect-DVLSSession -Configuration $configuration + $vaultIds = @(Get-DVLSVaultId -Session $session) + + If ($vaultIds.Count -EQ 0) { + Throw 'No accessible DVLS vaults were found.' + } + + [Void](Get-DVLSCredentialEntry -Session $session -VaultId $vaultIds[0]) + Return $True + } Catch { + Write-Error (Get-DVLSSafeErrorMessage -Action 'Devolutions Server vault test failed' -Exception $_.Exception) + Return $False + } Finally { + If ($session) { + Close-DVLSSession -Session $session + } + } + } +} + +#endregion diff --git a/Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.psd1 b/Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.psd1 new file mode 100644 index 0000000..95c2277 --- /dev/null +++ b/Modules/SecretManagement.DevolutionsServer/SecretManagement.DevolutionsServer.psd1 @@ -0,0 +1,29 @@ +# Public module manifest for SecretManagement.DevolutionsServer. +# +# PowerShell Universal and administrators import this manifest. It keeps the +# top-level module surface intentionally empty and loads the SecretManagement +# extension implementation through NestedModules. +@{ + ModuleVersion = '1.0.0' + GUID = 'c875a3af-688e-4341-a328-38afa6831c81' + Author = 'Devolutions' + CompanyName = 'Devolutions' + Copyright = '(c) 2026 Devolutions. All rights reserved.' + Description = 'Read-only SecretManagement extension vault for Devolutions Server (DVLS). Retrieves DVLS Credential entries as PSCredential objects for PowerShell SecretManagement, including PowerShell Universal.' + PowerShellVersion = '7.0' + RequiredModules = @('Microsoft.PowerShell.SecretManagement') + + NestedModules = @('SecretManagement.DevolutionsServer.Extension\SecretManagement.DevolutionsServer.Extension.psd1') + + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('SecretManagement', 'DevolutionsServer', 'DVLS', 'Vault', 'Credentials') + ReleaseNotes = 'Read-only DVLS Credential retrieval provider for Microsoft.PowerShell.SecretManagement.' + } + } +} diff --git a/Modules/SecretManagement.DevolutionsServer/Tests/SecretManagement.DevolutionsServer.Tests.ps1 b/Modules/SecretManagement.DevolutionsServer/Tests/SecretManagement.DevolutionsServer.Tests.ps1 new file mode 100644 index 0000000..4482232 --- /dev/null +++ b/Modules/SecretManagement.DevolutionsServer/Tests/SecretManagement.DevolutionsServer.Tests.ps1 @@ -0,0 +1,550 @@ +Set-StrictMode -Version Latest + +Describe 'module manifests' { + BeforeAll { + $script:ParentManifestPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'SecretManagement.DevolutionsServer.psd1' + $script:ExtensionManifestPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1' + } + + It 'targets PowerShell 7.0 in the parent manifest' { + $manifest = Test-ModuleManifest $script:ParentManifestPath + + $manifest.PowerShellVersion.ToString() | Should -Be '7.0' + } + + It 'targets PowerShell 7.0 in the extension manifest' { + $manifest = Test-ModuleManifest $script:ExtensionManifestPath + + $manifest.PowerShellVersion.ToString() | Should -Be '7.0' + } +} + +Describe 'read-only operations' { + BeforeAll { + $script:ExtensionManifestPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1' + Import-Module $script:ExtensionManifestPath -Force + } + + It 'rejects Set-Secret with a read-only error' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + { Set-Secret -Name 'Example' -Secret 'value' -VaultName 'DVLS' -AdditionalParameters @{} } | + Should -Throw '*read-only*' + } + } + + It 'rejects Remove-Secret with a read-only error' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + { Remove-Secret -Name 'Example' -VaultName 'DVLS' -AdditionalParameters @{} } | + Should -Throw '*read-only*' + } + } +} + +Describe 'configuration and request helpers' { + BeforeAll { + $script:ExtensionManifestPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1' + Import-Module $script:ExtensionManifestPath -Force + } + + It 'requires ServerUrl, AppKey, and AppSecret' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + { Get-DVLSVaultConfiguration -AdditionalParameters @{ ServerUrl = 'https://dvls.example.test'; AppSecret = 'secret'; VaultId = 'vault-1' } } | + Should -Throw "*AppKey*" + } + } + + It 'requires HTTPS server URLs' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + { Get-DVLSVaultConfiguration -AdditionalParameters @{ ServerUrl = 'http://dvls.example.test'; AppKey = 'key'; AppSecret = 'secret'; VaultId = 'vault-1' } } | + Should -Throw '*HTTPS*' + } + } + + It 'rejects server URLs with query strings or fragments' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + { Get-DVLSVaultConfiguration -AdditionalParameters @{ ServerUrl = 'https://dvls.example.test?tenant=prod'; AppKey = 'key'; AppSecret = 'secret'; VaultId = 'vault-1' } } | + Should -Throw '*query string or fragment*' + } + } + + It 'requires VaultId unless vault enumeration is explicitly enabled' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + { Get-DVLSVaultConfiguration -AdditionalParameters @{ ServerUrl = 'https://dvls.example.test'; AppKey = 'key'; AppSecret = 'secret' } } | + Should -Throw '*VaultId*' + } + } + + It 'rejects invalid boolean options without echoing caller-supplied values' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + $suppliedValue = 'secret-looking-value' + $exception = $Null + + Try { + Get-DVLSVaultConfiguration -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test' + AppKey = 'key' + AppSecret = 'secret' + AllowVaultEnumeration = $suppliedValue + } + } Catch { + $exception = $_.Exception + } + + $exception | Should -Not -BeNullOrEmpty + $exception.Message | Should -Match 'AllowVaultEnumeration' + $exception.Message | Should -Not -Match $suppliedValue + } + } + + It 'normalizes optional settings with production defaults' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + $configuration = Get-DVLSVaultConfiguration -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test/' + AppKey = 'key' + AppSecret = 'secret' + AllowVaultEnumeration = 'true' + } + + $configuration.ServerUrl | Should -Be 'https://dvls.example.test' + $configuration.AllowVaultEnumeration | Should -BeTrue + $configuration.RequestTimeoutSeconds | Should -Be 30 + $configuration.PageSize | Should -Be 100 + } + } + + It 'validates positive integer options' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + { Get-DVLSVaultConfiguration -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test' + AppKey = 'key' + AppSecret = 'secret' + AllowVaultEnumeration = $true + PageSize = 0 + } } | Should -Throw '*PageSize*' + } + } + + It 'escapes REST path segments and query string values' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + $uri = Join-DVLSApiUri ` + -ServerUrl 'https://dvls.example.test/base' ` + -PathSegments @('api', 'v1', 'vault', 'vault id', 'entry') ` + -Query ([ordered]@{ includePasswords = 'true'; name = 'domain user' }) + + $uri | Should -Match '^https://dvls\.example\.test/base/api/v1/vault/vault%20id/entry\?' + $uri | Should -Match 'includePasswords=true' + $uri | Should -Match 'name=domain%20user' + } + } + + It 'uses bounded REST request defaults' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + $options = Get-DVLSRequestOption -Configuration ([pscustomobject]@{ RequestTimeoutSeconds = 12 }) + + $options.ContentType | Should -Be 'application/json' + $options.SkipHttpErrorCheck | Should -BeTrue + $options.TimeoutSec | Should -Be 12 + $options.HttpVersion | Should -Be '2.0' + } + } +} + +Describe 'REST session and entry lookup helpers' { + BeforeAll { + $script:ExtensionManifestPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1' + Import-Module $script:ExtensionManifestPath -Force + } + + It 'logs in with app key and app secret' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + [pscustomobject]@{ tokenId = 'token-value' } + } -ParameterFilter { $Method -eq 'Post' -and $Uri -eq 'https://dvls.example.test/api/v1/login' } + + $configuration = Get-DVLSVaultConfiguration -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test' + AppKey = 'app-key' + AppSecret = 'app-secret' + VaultId = 'vault-1' + } + + $session = Connect-DVLSSession -Configuration $configuration + + $session.TokenId | Should -Be 'token-value' + Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Post' -and + $Uri -eq 'https://dvls.example.test/api/v1/login' -and + ($Body | ConvertFrom-Json).appKey -eq 'app-key' -and + ($Body | ConvertFrom-Json).appSecret -eq 'app-secret' + } + } + } + + It 'throws a safe authentication error when login response has no token' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + [pscustomobject]@{ authenticated = $true } + } + + $configuration = Get-DVLSVaultConfiguration -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test' + AppKey = 'app-key' + AppSecret = 'app-secret' + VaultId = 'vault-1' + } + + { Connect-DVLSSession -Configuration $configuration } | Should -Throw '*authentication failed*' + } + } + + It 'logs out with the active token' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + [pscustomobject]@{} + } -ParameterFilter { $Method -eq 'Post' -and $Uri -eq 'https://dvls.example.test/api/v1/logout' } + + $configuration = [pscustomobject]@{ ServerUrl = 'https://dvls.example.test'; RequestTimeoutSeconds = 30 } + $session = [pscustomobject]@{ TokenId = 'token-value'; ServerUrl = 'https://dvls.example.test'; Configuration = $configuration } + + Close-DVLSSession -Session $session + + Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Post' -and + $Uri -eq 'https://dvls.example.test/api/v1/logout' -and + $Headers.tokenId -eq 'token-value' + } + } + } + + It 'does not enumerate vaults when VaultId is configured' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { throw 'Vault enumeration should not be called.' } + + $session = [pscustomobject]@{ + TokenId = 'token-value' + ServerUrl = 'https://dvls.example.test' + Configuration = [pscustomobject]@{ + ServerUrl = 'https://dvls.example.test' + VaultId = 'vault-1' + AllowVaultEnumeration = $false + RequestTimeoutSeconds = 30 + } + } + + Get-DVLSVaultId -Session $session | Should -Be @('vault-1') + Should -Not -Invoke Invoke-RestMethod + } + } + + It 'lists only Credential entries across pages' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { + If ($Uri -like '*pageNumber=1*') { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + Return [pscustomobject]@{ + currentPage = 1 + totalPage = 2 + data = @( + [pscustomobject]@{ id = 'credential-1'; name = 'SqlProd'; type = 'Credential' }, + [pscustomobject]@{ id = 'note-1'; name = 'Note'; type = 'SecureNote' } + ) + } + } + + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + [pscustomobject]@{ + currentPage = 2 + totalPage = 2 + data = @( + [pscustomobject]@{ id = 'credential-2'; name = 'ApiProd'; type = 'Credential' } + ) + } + } + + $session = [pscustomobject]@{ + TokenId = 'token-value' + ServerUrl = 'https://dvls.example.test' + Configuration = [pscustomobject]@{ + ServerUrl = 'https://dvls.example.test' + VaultId = 'vault-1' + PageSize = 1 + RequestTimeoutSeconds = 30 + } + } + + $entries = @(Get-DVLSCredentialEntry -Session $session -VaultId 'vault-1') + + $entries.Name | Should -Be @('SqlProd', 'ApiProd') + Should -Invoke Invoke-RestMethod -Times 2 -Exactly -ParameterFilter { + $Method -eq 'Get' -and $Uri -like 'https://dvls.example.test/api/v1/vault/vault-1/entry*' + } + } + } + + It 'throws safe errors for HTTP failures' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 500 + [pscustomobject]@{ StatusCode = 500; message = 'server failed'; password = 'secret-password' } + } + + $configuration = [pscustomobject]@{ RequestTimeoutSeconds = 30 } + $errorRecord = $null + + Try { + Invoke-DVLSRestMethod -Method Get -Uri 'https://dvls.example.test/api/v1/vault' -Token 'token-value' -Configuration $configuration + } Catch { + $errorRecord = $_ + } + + $errorRecord | Should -Not -BeNullOrEmpty + $errorRecord.Exception.Message | Should -Match 'HTTP 500' + $errorRecord.Exception.Message | Should -Not -Match 'token-value|secret-password' + } + } +} + +Describe 'SecretManagement output behavior' { + BeforeAll { + $script:ExtensionManifestPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1' + Import-Module Microsoft.PowerShell.SecretManagement -ErrorAction Stop + Import-Module $script:ExtensionManifestPath -Force + } + + It 'returns null when Get-Secret finds no matching entry' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + Switch -Wildcard ($Uri) { + '*/login' { Return [pscustomobject]@{ tokenId = 'token-value' } } + '*/logout' { Return [pscustomobject]@{} } + '*entry*' { Return [pscustomobject]@{ currentPage = 1; totalPage = 1; data = @() } } + } + } + + $result = Get-Secret -Name 'Missing' -VaultName 'DVLS' -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test' + AppKey = 'app-key' + AppSecret = 'app-secret' + VaultId = 'vault-1' + } + + $result | Should -BeNullOrEmpty + } + } + + It 'returns a PSCredential for a matching DVLS Credential entry' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + Switch -Wildcard ($Uri) { + '*/login' { Return [pscustomobject]@{ tokenId = 'token-value' } } + '*/logout' { Return [pscustomobject]@{} } + '*/entry/credential-1*' { Return [pscustomobject]@{ data = [pscustomobject]@{ username = 'svc-sql'; password = 'P@ssw0rd!'; domain = 'example.test' } } } + '*vault/vault-1/entry*' { Return [pscustomobject]@{ currentPage = 1; totalPage = 1; data = @([pscustomobject]@{ id = 'credential-1'; name = 'SqlProd'; type = 'Credential' }) } } + } + } + + $credential = Get-Secret -Name 'SqlProd' -VaultName 'DVLS' -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test' + AppKey = 'app-key' + AppSecret = 'app-secret' + VaultId = 'vault-1' + } + + $credential | Should -BeOfType ([System.Management.Automation.PSCredential]) + $credential.UserName | Should -Be 'svc-sql@example.test' + $credential.GetNetworkCredential().Password | Should -Be 'P@ssw0rd!' + } + } + + It 'preserves already-qualified usernames when DVLS also returns a domain' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + Switch -Wildcard ($Uri) { + '*/login' { Return [pscustomobject]@{ tokenId = 'token-value' } } + '*/logout' { Return [pscustomobject]@{} } + '*/entry/credential-1*' { Return [pscustomobject]@{ data = [pscustomobject]@{ username = 'svc-sql@existing.test'; password = 'P@ssw0rd!'; domain = 'example.test' } } } + '*vault/vault-1/entry*' { Return [pscustomobject]@{ currentPage = 1; totalPage = 1; data = @([pscustomobject]@{ id = 'credential-1'; name = 'SqlProd'; type = 'Credential' }) } } + } + } + + $credential = Get-Secret -Name 'SqlProd' -VaultName 'DVLS' -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test' + AppKey = 'app-key' + AppSecret = 'app-secret' + VaultId = 'vault-1' + } + + $credential.UserName | Should -Be 'svc-sql@existing.test' + } + } + + It 'falls back to direct GUID lookup when no name match exists' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + $entryId = '11111111-1111-1111-1111-111111111111' + Mock Invoke-RestMethod { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + Switch -Wildcard ($Uri) { + '*/login' { Return [pscustomobject]@{ tokenId = 'token-value' } } + '*/logout' { Return [pscustomobject]@{} } + "*/entry/$entryId*" { Return [pscustomobject]@{ data = [pscustomobject]@{ id = $entryId; type = 'Credential'; username = 'svc-api'; password = 'Secret1!'; domain = '' } } } + '*vault/vault-1/entry*' { Return [pscustomobject]@{ currentPage = 1; totalPage = 1; data = @() } } + } + } + + $credential = Get-Secret -Name $entryId -VaultName 'DVLS' -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test' + AppKey = 'app-key' + AppSecret = 'app-secret' + VaultId = 'vault-1' + } + + $credential.UserName | Should -Be 'svc-api' + } + } + + It 'returns SecretInformation objects and applies wildcard filters' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + Switch -Wildcard ($Uri) { + '*/login' { Return [pscustomobject]@{ tokenId = 'token-value' } } + '*/logout' { Return [pscustomobject]@{} } + '*entry*' { + Return [pscustomobject]@{ + currentPage = 1 + totalPage = 1 + data = @( + [pscustomobject]@{ id = 'credential-1'; name = 'SqlProd'; type = 'Credential' }, + [pscustomobject]@{ id = 'credential-2'; name = 'ApiDev'; type = 'Credential' } + ) + } + } + } + } + + $info = @(Get-SecretInfo -Filter '*Prod' -VaultName 'DVLS' -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test' + AppKey = 'app-key' + AppSecret = 'app-secret' + VaultId = 'vault-1' + }) + + $info | Should -HaveCount 1 + $info[0].Name | Should -Be 'SqlProd' + $info[0].Type | Should -Be ([Microsoft.PowerShell.SecretManagement.SecretType]::PSCredential) + $info[0].VaultName | Should -Be 'DVLS' + } + } + + It 'returns exactly one Boolean from Test-SecretVault' { + InModuleScope SecretManagement.DevolutionsServer.Extension { + Mock Invoke-RestMethod { + Set-Variable -Name $StatusCodeVariable -Scope 1 -Value 200 + Switch -Wildcard ($Uri) { + '*/login' { Return [pscustomobject]@{ tokenId = 'token-value' } } + '*/logout' { Return [pscustomobject]@{} } + '*entry*' { Return [pscustomobject]@{ currentPage = 1; totalPage = 1; data = @() } } + } + } + + $result = @(Test-SecretVault -VaultName 'DVLS' -AdditionalParameters @{ + ServerUrl = 'https://dvls.example.test' + AppKey = 'app-key' + AppSecret = 'app-secret' + VaultId = 'vault-1' + }) + + $result | Should -HaveCount 1 + $result[0] | Should -BeTrue + } + } +} + +Describe 'PowerShell source documentation' { + BeforeAll { + $script:ProjectRoot = Split-Path -Parent $PSScriptRoot + } + + It 'documents module manifests and exported SecretManagement commands in source' { + $parentManifest = Get-Content -Raw (Join-Path $script:ProjectRoot 'SecretManagement.DevolutionsServer.psd1') + $extensionManifest = Get-Content -Raw (Join-Path $script:ProjectRoot 'SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psd1') + $moduleSource = Get-Content -Raw (Join-Path $script:ProjectRoot 'SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psm1') + + $parentManifest | Should -Match 'Public module manifest' + $extensionManifest | Should -Match 'SecretManagement extension manifest' + $moduleSource | Should -Match 'Configuration parser for SecretManagement VaultParameters' + $moduleSource | Should -Match '\.PARAMETER AdditionalParameters' + $moduleSource | Should -Match 'Called by Microsoft\.PowerShell\.SecretManagement' + $moduleSource | Should -Match 'Future mutable operations' + $moduleSource | Should -Match 'SuppressMessageAttribute' + } + + It 'keeps positive integer validation inline with vault configuration parsing' { + $moduleSource = Get-Content -Raw (Join-Path $script:ProjectRoot 'SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psm1') + + $moduleSource | Should -Not -Match 'Function ConvertTo-DVLSPositiveInt' + } + + It 'uses analyzer-friendly private helper names where the SecretManagement contract allows it' { + $moduleSource = Get-Content -Raw (Join-Path $script:ProjectRoot 'SecretManagement.DevolutionsServer.Extension/SecretManagement.DevolutionsServer.Extension.psm1') + + $moduleSource | Should -Match 'Function Join-DVLSApiUri' + $moduleSource | Should -Match 'Function Connect-DVLSSession' + $moduleSource | Should -Match 'Function ConvertTo-DVLSSecretInformation' + $moduleSource | Should -Not -Match 'Function New-DVLSApiUri' + $moduleSource | Should -Not -Match 'Function New-DVLSSession' + $moduleSource | Should -Not -Match 'Function New-DVLSSecretInformation' + } +} + +Describe 'project documentation' { + BeforeAll { + $script:ProjectRoot = Split-Path -Parent $PSScriptRoot + } + + It 'documents read-only PowerShell Universal usage in README.md' { + $readmePath = Join-Path $script:ProjectRoot 'README.md' + + $readmePath | Should -Exist + $content = Get-Content -Raw $readmePath + $content | Should -Match 'read-only' + $content | Should -Match 'PowerShell Universal' + $content | Should -Match 'Register-SecretVault' + $content | Should -Match 'Invoke-Pester' + $content | Should -Match 'Repository\\Modules' + $content | Should -Match '\.universal\\vaults\.ps1' + $content | Should -Match 'Create a PSU test script' + $content | Should -Match 'bootstrap' + $content | Should -Match 'inline credentials' + $content | Should -Match 'View password' + $content | Should -Match 'View sensitive information' + $content | Should -Match 'integrated environment' + $content | Should -Match 'Settings > Environments' + $content | Should -Match 'Future Mutable Operations' + $content | Should -Match 'SupportsShouldProcess' + $content | Should -Not -Match 'Save-Module Microsoft.PowerShell.SecretManagement' + } + + It 'documents agent guidance in AGENTS.md' { + $agentsPath = Join-Path $script:ProjectRoot 'AGENTS.md' + + $agentsPath | Should -Exist + $content = Get-Content -Raw $agentsPath + $content | Should -Match 'SecretManagement' + $content | Should -Match 'read-only' + $content | Should -Match 'Invoke-Pester' + $content | Should -Match 'Do not log' + $content | Should -Match '\.universal\\vaults\.ps1' + $content | Should -Match 'View password' + $content | Should -Match 'integrated environment' + $content | Should -Match 'Settings > Environments' + $content | Should -Match 'Future Mutable Operations' + $content | Should -Match 'SupportsShouldProcess' + } +}