From 3e2d6c9734f8a08b580ef8ad38dc00290d1e157e Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Sun, 3 May 2026 09:30:20 +0200 Subject: [PATCH 1/3] Add new resource secret store and test --- .../windows_secretstore/.project.data.json | 10 + .../test/windows_secretstore.config.tests.ps1 | 174 ++++++++ .../windows_secretstore.dsc.resource.json | 111 +++++ .../windows_secretstore.ps1 | 395 ++++++++++++++++++ .../windows_service.dsc.resource.json | 116 +++++ 5 files changed, 806 insertions(+) create mode 100644 resources/windows_secretstore/.project.data.json create mode 100644 resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 create mode 100644 resources/windows_secretstore/windows_secretstore.dsc.resource.json create mode 100644 resources/windows_secretstore/windows_secretstore.ps1 create mode 100644 resources/windows_secretstore/windows_service.dsc.resource.json diff --git a/resources/windows_secretstore/.project.data.json b/resources/windows_secretstore/.project.data.json new file mode 100644 index 000000000..396865f34 --- /dev/null +++ b/resources/windows_secretstore/.project.data.json @@ -0,0 +1,10 @@ +{ + "Name": "WindowsSecretStore", + "Kind": "Resource", + "CopyFiles": { + "Windows": [ + "windows_secretstore.ps1", + "windows_secretstore.dsc.resource.json" + ] + } +} diff --git a/resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 b/resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 new file mode 100644 index 000000000..17a6ebaab --- /dev/null +++ b/resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 @@ -0,0 +1,174 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows SecretStore config tests' -Skip:(!$IsWindows) { + BeforeAll { + Set-StrictMode -Version Latest + + $script:resourceRoot = Split-Path -Parent $PSScriptRoot + $script:resourceScript = Join-Path $script:resourceRoot 'windows_secretstore.ps1' + $script:configRoot = 'C:\Users\mmach\OneDrive\Skrypty\DSC\3.0\DSC\config' + + function Install-TestModule { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if (Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue | Select-Object -First 1) { + return + } + + if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -MinimumVersion '2.8.5.201' -Force -Scope CurrentUser | Out-Null + } + + Install-Module -Name $Name -Repository PSGallery -Scope CurrentUser -Force -AllowClobber -Confirm:$false -ErrorAction Stop + } + + foreach ($moduleName in @( + 'Pester', + 'powershell-yaml', + 'Microsoft.PowerShell.SecretManagement', + 'Microsoft.PowerShell.SecretStore' + )) { + Install-TestModule -Name $moduleName + } + + Import-Module powershell-yaml -Force -ErrorAction Stop + Import-Module Microsoft.PowerShell.SecretManagement -Force -ErrorAction Stop + Import-Module Microsoft.PowerShell.SecretStore -Force -ErrorAction Stop + + function Read-ConfigFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $yaml = ConvertFrom-Yaml -Yaml (Get-Content -Raw $Path -ErrorAction Stop) + return $yaml | ConvertTo-Json -Depth 20 | ConvertFrom-Json -AsHashtable + } + + $script:configs = @{ + none = Read-ConfigFile -Path (Join-Path $script:configRoot 'secret_store_none.yaml') + secure = Read-ConfigFile -Path (Join-Path $script:configRoot 'secret_store_secure.yaml') + } + + function Convert-ConfigToDesiredState { + param( + [Parameter(Mandatory = $true)] + [object]$Config + ) + + $properties = [ordered]@{} + $resourceProperties = $Config['resources'][0]['properties'] + + foreach ($propertyName in $resourceProperties.Keys) { + $properties[$propertyName] = $resourceProperties[$propertyName] + } + + $parameters = $Config['parameters'] + if ($null -eq $parameters) { + return $properties + } + + foreach ($parameterName in $parameters.Keys) { + $parameter = $parameters[$parameterName] + $parameterType = $parameter['type'] + $defaultValue = $parameter['defaultValue'] + + if ($parameterType -eq 'secureString' -and $properties['password'] -eq "[parameters('$parameterName')]") { + $properties['password'] = @{ secureString = [string]$defaultValue } + } + } + + return $properties + } + + function Invoke-SecretStoreOperation { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Get', 'Set', 'Test')] + [string]$Operation, + + [Parameter(Mandatory = $true)] + [hashtable]$DesiredState + ) + + $jsonInput = $DesiredState | ConvertTo-Json -Depth 10 -Compress + Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + $output = & $script:resourceScript $Operation $jsonInput 2>$testdrive/windows_secretstore.stderr + $lastExitCode = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + $exitCode = if ($null -ne $lastExitCode) { [int]$lastExitCode.Value } else { 0 } + $errorText = if (Test-Path $testdrive/windows_secretstore.stderr) { + Get-Content -Raw $testdrive/windows_secretstore.stderr + } + else { + '' + } + + [pscustomobject]@{ + ExitCode = $exitCode + StdOut = $output + StdErr = $errorText + State = if ($output) { $output | ConvertFrom-Json -Depth 10 } else { $null } + } + } + + function Reset-SecretStoreForTest { + Reset-SecretStore -Authentication None -Interaction None -PasswordTimeout -1 -Force -Confirm:$false -ErrorAction Stop | Out-Null + } + } + + BeforeEach { + Reset-SecretStoreForTest + } + + Context 'secret_store_none.yaml' { + It 'applies the none-auth configuration and reports desired state' { + $desiredState = Convert-ConfigToDesiredState -Config $script:configs.none + + $setResult = Invoke-SecretStoreOperation -Operation Set -DesiredState $desiredState + $setResult.ExitCode | Should -Be 0 -Because $setResult.StdErr + + $testResult = Invoke-SecretStoreOperation -Operation Test -DesiredState $desiredState + $testResult.ExitCode | Should -Be 0 -Because $testResult.StdErr + $testResult.State.authentication | Should -BeExactly 'None' + $testResult.State.interaction | Should -BeExactly 'None' + $testResult.State.passwordTimeout | Should -Be -1 + $testResult.State.scope | Should -BeExactly 'CurrentUser' + $testResult.State._inDesiredState | Should -BeTrue + } + } + + Context 'secret_store_secure.yaml' { + It 'accepts the secureString parameter shape and reports desired state' { + $desiredState = Convert-ConfigToDesiredState -Config $script:configs.secure + + $setResult = Invoke-SecretStoreOperation -Operation Set -DesiredState $desiredState + $setResult.ExitCode | Should -Be 0 -Because $setResult.StdErr + + $testResult = Invoke-SecretStoreOperation -Operation Test -DesiredState $desiredState + $testResult.ExitCode | Should -Be 0 -Because $testResult.StdErr + $testResult.State.authentication | Should -BeExactly 'Password' + $testResult.State.interaction | Should -BeExactly 'None' + $testResult.State.passwordTimeout | Should -Be -1 + $testResult.State.scope | Should -BeExactly 'CurrentUser' + $testResult.State._inDesiredState | Should -BeTrue + } + + It 'returns the configured state when Get is called with the secureString password shape' { + $desiredState = Convert-ConfigToDesiredState -Config $script:configs.secure + + $setResult = Invoke-SecretStoreOperation -Operation Set -DesiredState $desiredState + $setResult.ExitCode | Should -Be 0 -Because $setResult.StdErr + + $getResult = Invoke-SecretStoreOperation -Operation Get -DesiredState $desiredState + $getResult.ExitCode | Should -Be 0 -Because $getResult.StdErr + $getResult.State.authentication | Should -BeExactly 'Password' + $getResult.State.interaction | Should -BeExactly 'None' + $getResult.State.passwordTimeout | Should -Be -1 + $getResult.State.scope | Should -BeExactly 'CurrentUser' + } + } +} \ No newline at end of file diff --git a/resources/windows_secretstore/windows_secretstore.dsc.resource.json b/resources/windows_secretstore/windows_secretstore.dsc.resource.json new file mode 100644 index 000000000..224cc947c --- /dev/null +++ b/resources/windows_secretstore/windows_secretstore.dsc.resource.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.PowerShell/WindowsSecretStore", + "version": "0.1.0", + "description": "Manages the configuration of the Microsoft.PowerShell.SecretStore vault (authentication mode, password timeout, interaction policy, and scope).", + "tags": [ + "Windows", + "PowerShell", + "SecretStore", + "SecretManagement", + "Security" + ], + "condition": "[not(equals(tryWhich('pwsh'), null()))]", + "get": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./windows_secretstore.ps1 Get" + ], + "input": "stdin" + }, + "set": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./windows_secretstore.ps1 Set" + ], + "input": "stdin", + "implementsPretest": false, + "return": "state" + }, + "test": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./windows_secretstore.ps1 Test" + ], + "input": "stdin", + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Operation failed (module missing or cmdlet error)" + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Microsoft.PowerShell/WindowsSecretStore", + "description": "Properties that describe the configuration of the Microsoft.PowerShell.SecretStore vault.", + "type": "object", + "properties": { + "authentication": { + "title": "Authentication", + "description": "Specifies whether the SecretStore vault requires a password for access. This DSC resource runs non-interactively and only supports 'None' for unattended automation.", + "type": "string", + "enum": [ + "None", + "Prompt" + ] + }, + "passwordTimeout": { + "title": "Password Timeout", + "description": "Specifies how many seconds the vault remains unlocked after successful authentication. Use -1 to disable the timeout (vault stays unlocked indefinitely for the session).", + "type": "integer", + "minimum": -1 + }, + "interaction": { + "title": "Interaction", + "description": "Controls whether the vault is allowed to prompt the user for interaction (e.g., password input). This DSC resource runs non-interactively and only supports 'None'.", + "type": "string", + "enum": [ + "None" + ] + }, + "scope": { + "title": "Scope", + "description": "Specifies whether the SecretStore vault is configured for the current user ('CurrentUser') or all users on the machine ('AllUsers'). Changing scope may require elevated privileges.", + "type": "string", + "enum": [ + "CurrentUser", + "AllUsers" + ] + }, + "_inDesiredState": { + "title": "In Desired State", + "description": "Indicates whether the resource is in the desired state. Populated by the Test operation.", + "type": [ + "boolean", + "null" + ], + "default": null + } + } + } + } +} diff --git a/resources/windows_secretstore/windows_secretstore.ps1 b/resources/windows_secretstore/windows_secretstore.ps1 new file mode 100644 index 000000000..e5c045b03 --- /dev/null +++ b/resources/windows_secretstore/windows_secretstore.ps1 @@ -0,0 +1,395 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + DSC v3 resource script for managing Microsoft.PowerShell.SecretStore configuration. + +.DESCRIPTION + Implements Get, Set, and Test operations for the SecretStore vault configuration. + Requires the Microsoft.PowerShell.SecretStore module to be installed. + +.PARAMETER Operation + The DSC operation to perform: Get, Set, or Test. + +.PARAMETER jsonInput + JSON string received via pipeline containing the desired state properties. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateSet('Get', 'Set', 'Test')] + [string]$Operation, + + [Parameter(Mandatory = $true, Position = 1, ValueFromPipeline = $true)] + [string]$jsonInput +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Write-DscTrace { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Error', 'Warn', 'Info', 'Debug', 'Trace')] + [string]$Level, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string]$Message + ) + + $trace = @{ $Level.ToLower() = $Message } | ConvertTo-Json -Compress + $host.ui.WriteErrorLine($trace) +} + +function Assert-ModuleAvailable { + param([string]$ModuleName) + + if (-not (Get-Module -ListAvailable -Name $ModuleName -ErrorAction SilentlyContinue | + Select-Object -First 1)) { + Write-DscTrace -Level Error -Message ( + "Required module '$ModuleName' is not installed. " + + "Install it with: Install-Module -Name $ModuleName -Repository PSGallery -Force" + ) + exit 1 + } +} + +function Test-IsNonInteractiveSession { + <# + .SYNOPSIS + Detects if the current PowerShell process was started with -NonInteractive. + #> + try { + $commandLineArgs = [Environment]::GetCommandLineArgs() + foreach ($arg in $commandLineArgs) { + if ($arg -ieq '-NonInteractive') { + return $true + } + } + } + catch { + # If detection fails, default to interactive assumptions. + } + + return $false +} + +function ConvertTo-SecretStoreSecureString { + param( + [AllowNull()] + [AllowEmptyString()] + [object]$Value + ) + + if ($null -eq $Value) { + return $null + } + + if ($Value -is [System.Security.SecureString]) { + return $Value + } + + if ($Value -is [System.Collections.IDictionary] -and $Value.Contains('secureString')) { + $Value = $Value['secureString'] + } + elseif ($Value.PSObject.Properties['secureString']) { + $Value = $Value.secureString + } + + $plaintext = [string]$Value + if ([string]::IsNullOrEmpty($plaintext)) { + return $null + } + + return (ConvertTo-SecureString -String $plaintext -AsPlainText -Force) +} + +function Get-CurrentState { + <# + .SYNOPSIS + Returns a hashtable representing the current SecretStore configuration. + #> + param( + [switch]$SuppressNonInteractiveError, + + [AllowNull()] + [System.Security.SecureString]$Password + ) + + if ($null -ne $Password) { + try { + Unlock-SecretStore -Password $Password -ErrorAction Stop | Out-Null + } + catch { + Write-DscTrace -Level Error -Message "Failed to unlock SecretStore with the provided password: $_" + exit 1 + } + } + + try { + $config = Get-SecretStoreConfiguration -ErrorAction Stop + return [ordered]@{ + authentication = $config.Authentication.ToString() + passwordTimeout = [int]$config.PasswordTimeout + interaction = $config.Interaction.ToString() + scope = $config.Scope.ToString() + } + } + catch { + if ($_.ToString() -match 'NonInteractive mode|require interactive input') { + if ($SuppressNonInteractiveError) { + return [ordered]@{ + authentication = 'None' + passwordTimeout = 900 + interaction = 'None' + scope = 'CurrentUser' + requiresInteractiveInput = $true + } + } + + Write-DscTrace -Level Error -Message ( + "SecretStore is configured to require interactive input. " + + "This DSC resource runs PowerShell with -NonInteractive, so prompts are not allowed. " + + "Reconfigure SecretStore in an interactive session first, for example: " + + "Set-SecretStoreConfiguration -Authentication None -Interaction None -PasswordTimeout -1 -Confirm:`$false" + ) + exit 1 + } + + Write-DscTrace -Level Error -Message "Failed to retrieve SecretStore configuration: $_" + exit 1 + } +} + +function Ensure-SecretStoreVaultRegistered { + <# + .SYNOPSIS + Ensures the SecretStore vault is registered before configuration changes. + #> + try { + $vault = Get-SecretVault -Name 'SecretStore' -ErrorAction SilentlyContinue + if ($null -eq $vault) { + Register-SecretVault -Name 'SecretStore' -ModuleName 'Microsoft.PowerShell.SecretStore' -DefaultVault -ErrorAction Stop + Write-DscTrace -Level Info -Message 'Registered SecretStore vault.' + } + } + catch { + Write-DscTrace -Level Error -Message "Failed to register SecretStore vault: $_" + exit 1 + } +} + +# --------------------------------------------------------------------------- +# Prerequisites +# --------------------------------------------------------------------------- + +Assert-ModuleAvailable -ModuleName 'Microsoft.PowerShell.SecretStore' + +try { + Import-Module Microsoft.PowerShell.SecretStore -ErrorAction Stop +} +catch { + Write-DscTrace -Level Error -Message "Failed to import Microsoft.PowerShell.SecretStore: $_" + exit 1 +} + +# --------------------------------------------------------------------------- +# Parse input +# --------------------------------------------------------------------------- + +$desired = $null +try { + $desired = $jsonInput | ConvertFrom-Json -AsHashtable -ErrorAction Stop +} +catch { + Write-DscTrace -Level Error -Message "Failed to parse JSON input: $_" + exit 1 +} + +if ($null -eq $desired) { + $desired = @{} +} + +# --------------------------------------------------------------------------- +# Operations +# --------------------------------------------------------------------------- + +switch ($Operation) { + 'Get' { + try { + $suppressNonInteractiveError = Test-IsNonInteractiveSession + $password = $null + if ($desired.ContainsKey('password')) { + $password = ConvertTo-SecretStoreSecureString -Value $desired['password'] + } + + Get-CurrentState -SuppressNonInteractiveError:$suppressNonInteractiveError -Password $password | ConvertTo-Json -Compress + } + catch { + Write-DscTrace -Level Error -Message "Get operation failed: $_" + exit 1 + } + } + + 'Set' { + try { + $setParams = @{ Confirm = $false } + $password = $null + + if ($desired.ContainsKey('password')) { + $password = ConvertTo-SecretStoreSecureString -Value $desired['password'] + if ($null -eq $password) { + Write-DscTrace -Level Error -Message 'The password property was provided but is empty. Provide a non-empty SecureString value.' + exit 1 + } + + $setParams['Password'] = $password + $setParams['Authentication'] = 'Password' + } + + if ($desired.ContainsKey('authentication') -and -not $setParams.ContainsKey('Authentication')) { + $setParams['Authentication'] = $desired['authentication'] + } + if ($desired.ContainsKey('passwordTimeout')) { $setParams['PasswordTimeout'] = [int]$desired['passwordTimeout'] } + if ($desired.ContainsKey('interaction')) { $setParams['Interaction'] = $desired['interaction'] } + if ($desired.ContainsKey('scope')) { $setParams['Scope'] = $desired['scope'] } + + if ($setParams.ContainsKey('Authentication') -and $setParams['Authentication'] -eq 'Password' -and -not $setParams.ContainsKey('Password')) { + Write-DscTrace -Level Error -Message ( + 'Authentication was set to Password but no password property was provided. Supply password as a DSC SecureString parameter.' + ) + exit 1 + } + + if ($setParams.Count -eq 1) { + # Only Confirm was in params - nothing to change + Write-DscTrace -Level Info -Message 'No configurable properties specified; nothing to set.' + } + else { + Ensure-SecretStoreVaultRegistered + try { + Set-SecretStoreConfiguration @setParams -ErrorAction Stop + } + catch { + if ($_.ToString() -match 'NonInteractive mode|require interactive input') { + # If SecretStore requires prompts, reset it with the desired settings so DSC can proceed unattended. + $resetParams = @{ + Force = $true + Confirm = $false + } + + if ($setParams.ContainsKey('Authentication')) { $resetParams['Authentication'] = $setParams['Authentication'] } + if ($setParams.ContainsKey('Password')) { $resetParams['Password'] = $setParams['Password'] } + if ($setParams.ContainsKey('PasswordTimeout')) { $resetParams['PasswordTimeout'] = $setParams['PasswordTimeout'] } + if ($setParams.ContainsKey('Interaction')) { $resetParams['Interaction'] = $setParams['Interaction'] } + if ($setParams.ContainsKey('Scope')) { $resetParams['Scope'] = $setParams['Scope'] } + + Write-DscTrace -Level Warn -Message ( + 'SecretStore requires interactive input; attempting Reset-SecretStore with desired settings to enable unattended DSC execution.' + ) + Reset-SecretStore @resetParams -ErrorAction Stop + } + else { + throw + } + } + Write-DscTrace -Level Info -Message 'SecretStore configuration updated successfully.' + } + + # Return the resulting state without surfacing interactive prompts in DSC's noninteractive host. + $suppressNonInteractiveError = Test-IsNonInteractiveSession + Get-CurrentState -SuppressNonInteractiveError:$suppressNonInteractiveError -Password $password | ConvertTo-Json -Compress + } + catch { + if ($_.ToString() -match 'NonInteractive mode|require interactive input') { + Write-DscTrace -Level Error -Message ( + "Set operation requires interactive input with the current SecretStore settings. " + + "Run this once in an interactive PowerShell session to allow unattended DSC runs: " + + "Set-SecretStoreConfiguration -Authentication None -Interaction None -PasswordTimeout -1 -Confirm:`$false" + ) + exit 1 + } + + Write-DscTrace -Level Error -Message "Set operation failed: $_" + exit 1 + } + } + + 'Test' { + try { + $password = $null + if ($desired.ContainsKey('password')) { + $password = ConvertTo-SecretStoreSecureString -Value $desired['password'] + } + + $current = Get-CurrentState -SuppressNonInteractiveError -Password $password + $inDesiredState = $true + $normalizedDesiredAuthentication = $null + + if ($desired.ContainsKey('password')) { + $normalizedDesiredAuthentication = 'Password' + } + elseif ($desired.ContainsKey('authentication')) { + $normalizedDesiredAuthentication = $desired['authentication'] + } + + if ($current['requiresInteractiveInput']) { + Write-DscTrace -Level Info -Message ( + 'SecretStore currently requires interactive input, so it is not in the desired state for unattended DSC execution.' + ) + $inDesiredState = $false + } + + $propertyMap = @{ + authentication = 'authentication' + passwordTimeout = 'passwordTimeout' + interaction = 'interaction' + scope = 'scope' + } + + foreach ($key in $propertyMap.Keys) { + $hasDesiredValue = $desired.ContainsKey($key) + $desiredValue = $null + + if ($key -eq 'authentication' -and $null -ne $normalizedDesiredAuthentication) { + $hasDesiredValue = $true + $desiredValue = $normalizedDesiredAuthentication + } + elseif ($hasDesiredValue) { + $desiredValue = $desired[$key] + } + + if ($hasDesiredValue) { + $currentValue = $current[$key] + + if ($current['requiresInteractiveInput']) { + continue + } + + # Normalize integer comparison + if ($key -eq 'passwordTimeout') { + $desiredValue = [int]$desiredValue + $currentValue = [int]$currentValue + } + + if ($currentValue -ne $desiredValue) { + Write-DscTrace -Level Info -Message ( + "Property '$key' is not in desired state. " + + "Current: '$currentValue', Desired: '$desiredValue'." + ) + $inDesiredState = $false + } + } + } + + $current['_inDesiredState'] = $inDesiredState + $current | ConvertTo-Json -Compress + } + catch { + Write-DscTrace -Level Error -Message "Test operation failed: $_" + exit 1 + } + } +} diff --git a/resources/windows_secretstore/windows_service.dsc.resource.json b/resources/windows_secretstore/windows_service.dsc.resource.json new file mode 100644 index 000000000..f37083a82 --- /dev/null +++ b/resources/windows_secretstore/windows_service.dsc.resource.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Windows/Service", + "description": "Manage Windows services", + "tags": [ + "Windows" + ], + "version": "0.1.0", + "get": { + "executable": "windows_service", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "windows_service", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "return": "state", + "requireSecurityContext": "elevated" + }, + "export": { + "executable": "windows_service", + "args": [ + "export", + { + "jsonInputArg": "--input", + "mandatory": false + } + ] + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Service error" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Windows Service", + "description": "Manage Windows services.", + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Service name", + "description": "The name of the service in the Service Control Manager." + }, + "displayName": { + "type": "string", + "title": "Display name", + "description": "The display name of the service shown in the Services console." + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the service." + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Indicates whether the service exists.", + "readOnly": true + }, + "status": { + "type": "string", + "title": "Status", + "description": "The status of the service. When used as desired state, only \"Running\", \"Stopped\", and \"Paused\" are accepted. Additional values (\"StartPending\", \"StopPending\", \"PausePending\", \"ContinuePending\") may be returned to report the current status but must not be used as desired values.", + "enum": ["Running", "Stopped", "Paused", "StartPending", "StopPending", "PausePending", "ContinuePending"] + }, + "startType": { + "type": "string", + "title": "Start type", + "description": "The start type of the service.", + "enum": ["Automatic", "AutomaticDelayedStart", "Manual", "Disabled"] + }, + "executablePath": { + "type": "string", + "title": "Executable path", + "description": "The fully qualified path to the service binary." + }, + "logonAccount": { + "type": "string", + "title": "Logon account", + "description": "The account under which the service runs." + }, + "errorControl": { + "type": "string", + "title": "Error control", + "description": "The error control level for the service.", + "enum": ["Ignore", "Normal", "Severe", "Critical"] + }, + "dependencies": { + "type": "array", + "title": "Dependencies", + "description": "A list of service names that this service depends on.", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } +} From da36e1e44d40feb4a564e61490ef047499dee4d3 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Sun, 3 May 2026 11:50:32 +0200 Subject: [PATCH 2/3] Add new resource secret store and test --- .../windows_service.dsc.resource.json | 116 ------------------ 1 file changed, 116 deletions(-) delete mode 100644 resources/windows_secretstore/windows_service.dsc.resource.json diff --git a/resources/windows_secretstore/windows_service.dsc.resource.json b/resources/windows_secretstore/windows_service.dsc.resource.json deleted file mode 100644 index f37083a82..000000000 --- a/resources/windows_secretstore/windows_service.dsc.resource.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", - "type": "Microsoft.Windows/Service", - "description": "Manage Windows services", - "tags": [ - "Windows" - ], - "version": "0.1.0", - "get": { - "executable": "windows_service", - "args": [ - "get", - { - "jsonInputArg": "--input", - "mandatory": true - } - ] - }, - "set": { - "executable": "windows_service", - "args": [ - "set", - { - "jsonInputArg": "--input", - "mandatory": true - } - ], - "implementsPretest": false, - "return": "state", - "requireSecurityContext": "elevated" - }, - "export": { - "executable": "windows_service", - "args": [ - "export", - { - "jsonInputArg": "--input", - "mandatory": false - } - ] - }, - "exitCodes": { - "0": "Success", - "1": "Invalid arguments", - "2": "Invalid input", - "3": "Service error" - }, - "schema": { - "embedded": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Windows Service", - "description": "Manage Windows services.", - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "Service name", - "description": "The name of the service in the Service Control Manager." - }, - "displayName": { - "type": "string", - "title": "Display name", - "description": "The display name of the service shown in the Services console." - }, - "description": { - "type": "string", - "title": "Description", - "description": "A description of the service." - }, - "_exist": { - "type": "boolean", - "title": "Exists", - "description": "Indicates whether the service exists.", - "readOnly": true - }, - "status": { - "type": "string", - "title": "Status", - "description": "The status of the service. When used as desired state, only \"Running\", \"Stopped\", and \"Paused\" are accepted. Additional values (\"StartPending\", \"StopPending\", \"PausePending\", \"ContinuePending\") may be returned to report the current status but must not be used as desired values.", - "enum": ["Running", "Stopped", "Paused", "StartPending", "StopPending", "PausePending", "ContinuePending"] - }, - "startType": { - "type": "string", - "title": "Start type", - "description": "The start type of the service.", - "enum": ["Automatic", "AutomaticDelayedStart", "Manual", "Disabled"] - }, - "executablePath": { - "type": "string", - "title": "Executable path", - "description": "The fully qualified path to the service binary." - }, - "logonAccount": { - "type": "string", - "title": "Logon account", - "description": "The account under which the service runs." - }, - "errorControl": { - "type": "string", - "title": "Error control", - "description": "The error control level for the service.", - "enum": ["Ignore", "Normal", "Severe", "Critical"] - }, - "dependencies": { - "type": "array", - "title": "Dependencies", - "description": "A list of service names that this service depends on.", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - } - } -} From 26ff4f578b3219a414ac65e227cd7046309d907c Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Sun, 3 May 2026 21:27:06 +0200 Subject: [PATCH 3/3] Add test for secure store --- .../test/windows_secretstore.config.tests.ps1 | 193 +++++++----------- .../windows_secretstore.dsc.resource.json | 5 +- .../windows_secretstore.ps1 | 36 +++- 3 files changed, 111 insertions(+), 123 deletions(-) diff --git a/resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 b/resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 index 17a6ebaab..8b4f4797b 100644 --- a/resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 +++ b/resources/windows_secretstore/test/windows_secretstore.config.tests.ps1 @@ -5,10 +5,6 @@ Describe 'Windows SecretStore config tests' -Skip:(!$IsWindows) { BeforeAll { Set-StrictMode -Version Latest - $script:resourceRoot = Split-Path -Parent $PSScriptRoot - $script:resourceScript = Join-Path $script:resourceRoot 'windows_secretstore.ps1' - $script:configRoot = 'C:\Users\mmach\OneDrive\Skrypty\DSC\3.0\DSC\config' - function Install-TestModule { param( [Parameter(Mandatory = $true)] @@ -28,147 +24,116 @@ Describe 'Windows SecretStore config tests' -Skip:(!$IsWindows) { foreach ($moduleName in @( 'Pester', - 'powershell-yaml', 'Microsoft.PowerShell.SecretManagement', 'Microsoft.PowerShell.SecretStore' )) { Install-TestModule -Name $moduleName } - Import-Module powershell-yaml -Force -ErrorAction Stop Import-Module Microsoft.PowerShell.SecretManagement -Force -ErrorAction Stop Import-Module Microsoft.PowerShell.SecretStore -Force -ErrorAction Stop - function Read-ConfigFile { - param( - [Parameter(Mandatory = $true)] - [string]$Path - ) - - $yaml = ConvertFrom-Yaml -Yaml (Get-Content -Raw $Path -ErrorAction Stop) - return $yaml | ConvertTo-Json -Depth 20 | ConvertFrom-Json -AsHashtable - } - - $script:configs = @{ - none = Read-ConfigFile -Path (Join-Path $script:configRoot 'secret_store_none.yaml') - secure = Read-ConfigFile -Path (Join-Path $script:configRoot 'secret_store_secure.yaml') + function Reset-SecretStoreForTest { + Reset-SecretStore -Authentication None -Interaction None -PasswordTimeout -1 -Force -Confirm:$false -ErrorAction Stop | Out-Null } - function Convert-ConfigToDesiredState { - param( - [Parameter(Mandatory = $true)] - [object]$Config - ) - - $properties = [ordered]@{} - $resourceProperties = $Config['resources'][0]['properties'] + # Inline DSC config: none-auth (unattended automation) + $script:configNone = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: + - name: Configure SecretStore for unattended automation + type: Microsoft.PowerShell/WindowsSecretStore + properties: + authentication: None + passwordTimeout: -1 + interaction: None + scope: CurrentUser +'@ + + # Inline DSC config: password-auth with secureString parameter + $script:configPassword = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + SecretPassword: + type: secureString + defaultValue: TestSecretValue +resources: + - name: Configure SecretStore for unattended automation + type: Microsoft.PowerShell/WindowsSecretStore + properties: + authentication: Password + passwordTimeout: -1 + interaction: None + scope: CurrentUser + password: "[parameters('SecretPassword')]" +'@ + } - foreach ($propertyName in $resourceProperties.Keys) { - $properties[$propertyName] = $resourceProperties[$propertyName] - } + BeforeEach { + Reset-SecretStoreForTest + } - $parameters = $Config['parameters'] - if ($null -eq $parameters) { - return $properties - } + Context 'dsc config set then test (none-auth)' { + It 'applies the none-auth configuration via dsc config set' { + $null = dsc config set -i $script:configNone 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") + } - foreach ($parameterName in $parameters.Keys) { - $parameter = $parameters[$parameterName] - $parameterType = $parameter['type'] - $defaultValue = $parameter['defaultValue'] + It 'reports desired state via dsc config test' { + $null = dsc config set -i $script:configNone 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") - if ($parameterType -eq 'secureString' -and $properties['password'] -eq "[parameters('$parameterName')]") { - $properties['password'] = @{ secureString = [string]$defaultValue } - } - } + $out = dsc config test -i $script:configNone 2>"$TestDrive/test.stderr" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/test.stderr") - return $properties + $result = $out.results[0].result + $result.inDesiredState | Should -BeTrue } - function Invoke-SecretStoreOperation { - param( - [Parameter(Mandatory = $true)] - [ValidateSet('Get', 'Set', 'Test')] - [string]$Operation, - - [Parameter(Mandatory = $true)] - [hashtable]$DesiredState - ) - - $jsonInput = $DesiredState | ConvertTo-Json -Depth 10 -Compress - Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue - $output = & $script:resourceScript $Operation $jsonInput 2>$testdrive/windows_secretstore.stderr - $lastExitCode = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue - $exitCode = if ($null -ne $lastExitCode) { [int]$lastExitCode.Value } else { 0 } - $errorText = if (Test-Path $testdrive/windows_secretstore.stderr) { - Get-Content -Raw $testdrive/windows_secretstore.stderr - } - else { - '' - } + It 'returns current state via dsc config get' { + $null = dsc config set -i $script:configNone 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") - [pscustomobject]@{ - ExitCode = $exitCode - StdOut = $output - StdErr = $errorText - State = if ($output) { $output | ConvertFrom-Json -Depth 10 } else { $null } - } - } + $out = dsc config get -i $script:configNone 2>"$TestDrive/get.stderr" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/get.stderr") - function Reset-SecretStoreForTest { - Reset-SecretStore -Authentication None -Interaction None -PasswordTimeout -1 -Force -Confirm:$false -ErrorAction Stop | Out-Null + $actualState = $out.results[0].result.actualState + $actualState.authentication | Should -BeExactly 'None' + $actualState.interaction | Should -BeExactly 'None' + $actualState.passwordTimeout | Should -Be -1 + $actualState.scope | Should -BeExactly 'CurrentUser' } } - BeforeEach { - Reset-SecretStoreForTest - } - - Context 'secret_store_none.yaml' { - It 'applies the none-auth configuration and reports desired state' { - $desiredState = Convert-ConfigToDesiredState -Config $script:configs.none - - $setResult = Invoke-SecretStoreOperation -Operation Set -DesiredState $desiredState - $setResult.ExitCode | Should -Be 0 -Because $setResult.StdErr - - $testResult = Invoke-SecretStoreOperation -Operation Test -DesiredState $desiredState - $testResult.ExitCode | Should -Be 0 -Because $testResult.StdErr - $testResult.State.authentication | Should -BeExactly 'None' - $testResult.State.interaction | Should -BeExactly 'None' - $testResult.State.passwordTimeout | Should -Be -1 - $testResult.State.scope | Should -BeExactly 'CurrentUser' - $testResult.State._inDesiredState | Should -BeTrue + Context 'dsc config set then test (password-auth)' { + It 'applies the password-auth configuration via dsc config set' { + $null = dsc config set -i $script:configPassword 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") } - } - Context 'secret_store_secure.yaml' { - It 'accepts the secureString parameter shape and reports desired state' { - $desiredState = Convert-ConfigToDesiredState -Config $script:configs.secure + It 'reports desired state via dsc config test' { + $null = dsc config set -i $script:configPassword 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") - $setResult = Invoke-SecretStoreOperation -Operation Set -DesiredState $desiredState - $setResult.ExitCode | Should -Be 0 -Because $setResult.StdErr + $out = dsc config test -i $script:configPassword 2>"$TestDrive/test.stderr" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/test.stderr") - $testResult = Invoke-SecretStoreOperation -Operation Test -DesiredState $desiredState - $testResult.ExitCode | Should -Be 0 -Because $testResult.StdErr - $testResult.State.authentication | Should -BeExactly 'Password' - $testResult.State.interaction | Should -BeExactly 'None' - $testResult.State.passwordTimeout | Should -Be -1 - $testResult.State.scope | Should -BeExactly 'CurrentUser' - $testResult.State._inDesiredState | Should -BeTrue + $result = $out.results[0].result + $result.inDesiredState | Should -BeTrue } - It 'returns the configured state when Get is called with the secureString password shape' { - $desiredState = Convert-ConfigToDesiredState -Config $script:configs.secure + It 'returns current state via dsc config get' { + $null = dsc config set -i $script:configPassword 2>"$TestDrive/set.stderr" + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/set.stderr") - $setResult = Invoke-SecretStoreOperation -Operation Set -DesiredState $desiredState - $setResult.ExitCode | Should -Be 0 -Because $setResult.StdErr + $out = dsc config get -i $script:configPassword 2>"$TestDrive/get.stderr" | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw "$TestDrive/get.stderr") - $getResult = Invoke-SecretStoreOperation -Operation Get -DesiredState $desiredState - $getResult.ExitCode | Should -Be 0 -Because $getResult.StdErr - $getResult.State.authentication | Should -BeExactly 'Password' - $getResult.State.interaction | Should -BeExactly 'None' - $getResult.State.passwordTimeout | Should -Be -1 - $getResult.State.scope | Should -BeExactly 'CurrentUser' + $actualState = $out.results[0].result.actualState + $actualState.authentication | Should -BeExactly 'Password' + $actualState.interaction | Should -BeExactly 'None' + $actualState.passwordTimeout | Should -Be -1 + $actualState.scope | Should -BeExactly 'CurrentUser' } } } \ No newline at end of file diff --git a/resources/windows_secretstore/windows_secretstore.dsc.resource.json b/resources/windows_secretstore/windows_secretstore.dsc.resource.json index 224cc947c..714095e99 100644 --- a/resources/windows_secretstore/windows_secretstore.dsc.resource.json +++ b/resources/windows_secretstore/windows_secretstore.dsc.resource.json @@ -70,7 +70,8 @@ "type": "string", "enum": [ "None", - "Prompt" + "Prompt", + "Password" ] }, "passwordTimeout": { @@ -108,4 +109,4 @@ } } } -} +} \ No newline at end of file diff --git a/resources/windows_secretstore/windows_secretstore.ps1 b/resources/windows_secretstore/windows_secretstore.ps1 index e5c045b03..c4eb07592 100644 --- a/resources/windows_secretstore/windows_secretstore.ps1 +++ b/resources/windows_secretstore/windows_secretstore.ps1 @@ -123,8 +123,16 @@ function Get-CurrentState { Unlock-SecretStore -Password $Password -ErrorAction Stop | Out-Null } catch { - Write-DscTrace -Level Error -Message "Failed to unlock SecretStore with the provided password: $_" - exit 1 + # If the store is currently configured for None authentication, unlocking is not required. + # This happens when Test/Get is called with a password to verify a desired Password-auth state + # but the store hasn't been reconfigured yet (still in None mode). + if ($_.ToString() -match 'not configured to use a password') { + # No unlock needed; fall through to Get-SecretStoreConfiguration. + } + else { + Write-DscTrace -Level Error -Message "Failed to unlock SecretStore with the provided password: $_" + exit 1 + } } } @@ -273,8 +281,15 @@ switch ($Operation) { Set-SecretStoreConfiguration @setParams -ErrorAction Stop } catch { - if ($_.ToString() -match 'NonInteractive mode|require interactive input') { - # If SecretStore requires prompts, reset it with the desired settings so DSC can proceed unattended. + # Two known cases where Set-SecretStoreConfiguration cannot proceed and we fall back to Reset-SecretStore: + # 1. The host is non-interactive so the module cannot prompt. + # 2. We are transitioning to Password auth but the store is currently in None auth mode; + # the module tries to Unlock-SecretStore with the new password before reconfiguring, + # which fails because the store is not yet in Password mode. + $isNonInteractive = $_.ToString() -match 'NonInteractive mode|require interactive input' + $isAuthMismatch = $_.ToString() -match 'not configured to use a password' + + if ($isNonInteractive -or $isAuthMismatch) { $resetParams = @{ Force = $true Confirm = $false @@ -286,8 +301,13 @@ switch ($Operation) { if ($setParams.ContainsKey('Interaction')) { $resetParams['Interaction'] = $setParams['Interaction'] } if ($setParams.ContainsKey('Scope')) { $resetParams['Scope'] = $setParams['Scope'] } + $reason = if ($isAuthMismatch) { + 'Store is in None-auth mode; transitioning to Password auth requires a full reset.' + } else { + 'SecretStore requires interactive input.' + } Write-DscTrace -Level Warn -Message ( - 'SecretStore requires interactive input; attempting Reset-SecretStore with desired settings to enable unattended DSC execution.' + "$reason Attempting Reset-SecretStore with desired settings to enable unattended DSC execution." ) Reset-SecretStore @resetParams -ErrorAction Stop } @@ -298,7 +318,9 @@ switch ($Operation) { Write-DscTrace -Level Info -Message 'SecretStore configuration updated successfully.' } - # Return the resulting state without surfacing interactive prompts in DSC's noninteractive host. + # Return the resulting state. Pass the password so Unlock-SecretStore can open the vault if + # the store was just transitioned to Password auth. Get-CurrentState silently skips the unlock + # when the store is still in None-auth mode (see the 'not configured to use a password' guard). $suppressNonInteractiveError = Test-IsNonInteractiveSession Get-CurrentState -SuppressNonInteractiveError:$suppressNonInteractiveError -Password $password | ConvertTo-Json -Compress } @@ -392,4 +414,4 @@ switch ($Operation) { exit 1 } } -} +} \ No newline at end of file