fix(install): worker isolation survives a folder swap: recorded settings, -Reapply, -Verify, swap script (#901)
v2-ci / unit-tests (push) Has been cancelled

An upgrade that swaps the install folder dropped the explicit ScriptWorker isolation ACEs on script-worker\, the
install folder and a copied-back pki\ (vd03 2026-11-13). Re-applying needed the operator to repeat the arguments.

- Install-ScriptWorkerIsolation.ps1 records its effective settings (InstallRoot, HostServiceAccount, PeCacheDir,
  HostServiceName, ConfigureHostService) beside DenyPaths under ...\Services\OtOpcUaScriptWorker\Parameters (new plan
  step SettingsRecord) and locks that key to SYSTEM + Administrators (protected DACL; no secrets recorded).
- -Reapply: re-apply from the record, no arguments; prints a summary line (changed/unchanged).
- -Verify: check only, [ok]/[MISSING] per step, exit 1 when anything is missing. -Uninstall also works from the record.
  A node isolated by a pre-#901 installer (DenyPaths only) must run the full-argument install once; both modes say so.
- Invoke-OtOpcUaInstallSwap.ps1: preflight -> stop -> move live/candidate -> carry appsettings/pki/logs ->
  -Reapply -> -Verify -> start; a failed -Verify leaves the service stopped. Also the rollback (-NoCarry).
- Tests: dry-run harness 101/101 (record + argument rules; record-key SDDL mutation turns 2 red). The Windows test adds
  swap -> Verify fails (exactly the 3 lost ACEs) -> Reapply (3 changed) -> Verify ok -> Reapply 12/12 unchanged, PE-cache
  and firewall negative controls, the swap script, and the pre-#901 refusal; 52/52 on windev under 5.1 and 7, with a
  scratch local host account and with SYSTEM. Evidence: scripts/validation/opcua-windows-service/evidence/2026-11-13-windev-901-reapply-verify.
- Docs: ServiceHosting isolation section; vd03 pack (swap step, rollback, post-deploy gate, one-time full-argument run),
  the 2026-11-13 vd03 record, and the QA pack (QA swap preceded the isolation install; ACLs checked intact over SMB on
  vq03/vq04 2026-11-13; next-deploy procedure).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 02:06:10 -05:00
parent 2470f82eea
commit 2cb8a2c317
10 changed files with 748 additions and 36 deletions
+160 -10
View File
@@ -33,6 +33,13 @@ $script:DenyMask = 0x0D0157
# anchor service's key and goes with it.
$script:DenyRecordValue = 'DenyPaths'
# lmxopcua#901: the effective install arguments, recorded beside DenyPaths so `-Reapply` (after an upgrade swapped the
# install folder) and `-Verify` (post-deploy gate) need no arguments. Paths, an account name and two flags; no secrets.
# The key's DACL is set protected, SYSTEM + Administrators only (the services hive is otherwise readable by Users, one of
# the worker token's restricting SIDs, and the record lists exactly which folders are denied).
$script:SettingsValueNames = @('InstallRoot', 'HostServiceAccount', 'PeCacheDir', 'HostServiceName', 'ConfigureHostService')
$script:RecordKeySddl = 'D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)'
$script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
$script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
$script:EnvIdentity = 'Scripting__Worker__Identity'
@@ -162,6 +169,8 @@ function Get-ScriptWorkerIsolationPlan {
# The -DenyPaths a previous run recorded (read by the entry script from the anchor service's key; empty under
# -PlanOnly). Install removes the deny from recorded paths no longer listed; -Uninstall removes it from all.
[string[]]$RecordedDenyPaths = @(),
# lmxopcua#901: the -HostServiceAccount as the operator gave it (recorded for -Reapply). Defaults to the SID.
[string]$HostServiceAccount,
[switch]$ConfigureHostService,
[switch]$Uninstall,
[switch]$RemovePeCache
@@ -200,6 +209,13 @@ function Get-ScriptWorkerIsolationPlan {
$steps.Add((New-OtOpcUaPlanStep 'DenyPathRecord' $recordKey `
"record the denied folders ($(if ($deny.Count) { $deny -join '; ' } else { 'none beyond the install folder' })) so a re-run or -Uninstall removes exactly these" `
@{ Key = $recordKey; Name = $script:DenyRecordValue; Paths = $deny }))
$settings = [ordered]@{
InstallRoot = $root; HostServiceAccount = $(if ($HostServiceAccount) { $HostServiceAccount } else { $HostSid })
PeCacheDir = $cache; HostServiceName = $HostServiceName; ConfigureHostService = [int][bool]$ConfigureHostService
}
$steps.Add((New-OtOpcUaPlanStep 'SettingsRecord' $recordKey `
"record the effective settings (InstallRoot, HostServiceAccount, PeCacheDir, HostServiceName, ConfigureHostService) and lock the key to SYSTEM + Administrators, so -Reapply and -Verify need no arguments (lmxopcua#901)" `
@{ Key = $recordKey; Settings = $settings; Sddl = $script:RecordKeySddl }))
$steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache `
"create the PE cache directory and set its ACL (protected; SYSTEM, Administrators, host account full control; no Users; no worker access to the key or markers)" `
@{ Path = $cache; Sddl = (Get-OtOpcUaPeCacheSddl -HostSid $HostSid) }))
@@ -265,13 +281,9 @@ function Get-ScriptWorkerIsolationPlan {
# returns 'changed' or 'unchanged'. Callers gate every call with ShouldProcess, so -WhatIf runs none of them.
# ---------------------------------------------------------------------------------------------------------------------
function Set-OtOpcUaExplicitAce {
# Ensures exactly one explicit ACE for $Sid on $Path with the given type/mask/inheritance (or none when $Remove).
function Get-OtOpcUaExplicitAceState {
# The explicit ACEs for $Sid on $Path and whether they are exactly the wanted one (or none when $Remove).
param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove)
if (-not (Test-Path -LiteralPath $Path)) {
if ($Remove) { return 'unchanged' }
throw "$Path does not exist"
}
$acl = Get-Acl -LiteralPath $Path # #800: DirectoryInfo.GetAccessControl does not exist on PowerShell 7 (.NET)
$identity = [System.Security.Principal.SecurityIdentifier]::new($Sid)
$explicit = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
@@ -287,9 +299,22 @@ function Set-OtOpcUaExplicitAce {
$wanted.Count -eq 0 -or (
[int]$explicit[0].FileSystemRights -eq $Mask -and $explicit[0].AccessControlType -eq $wanted[0].AccessControlType -and
$explicit[0].InheritanceFlags -eq $inherit -and $explicit[0].PropagationFlags -eq 'None'))
if ($same) { return 'unchanged' }
$acl.PurgeAccessRules($identity)
foreach ($rule in $wanted) { $acl.AddAccessRule($rule) }
$found = @($explicit | ForEach-Object { '{0} 0x{1:x} {2}' -f $_.AccessControlType, [int]$_.FileSystemRights, $_.InheritanceFlags }) -join '; '
[pscustomobject]@{ Acl = $acl; Identity = $identity; Wanted = $wanted; Same = $same; Found = $(if ($found) { $found } else { 'none' }) }
}
function Set-OtOpcUaExplicitAce {
# Ensures exactly one explicit ACE for $Sid on $Path with the given type/mask/inheritance (or none when $Remove).
param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove)
if (-not (Test-Path -LiteralPath $Path)) {
if ($Remove) { return 'unchanged' }
throw "$Path does not exist"
}
$state = Get-OtOpcUaExplicitAceState -Path $Path -Sid $Sid -Mask $Mask -Type $Type -Remove:$Remove
if ($state.Same) { return 'unchanged' }
$acl = $state.Acl
$acl.PurgeAccessRules($state.Identity)
foreach ($rule in $state.Wanted) { $acl.AddAccessRule($rule) }
Set-Acl -LiteralPath $Path -AclObject $acl
'changed'
}
@@ -388,6 +413,27 @@ function Invoke-ScriptWorkerIsolationStep {
else { Set-ItemProperty -LiteralPath $d.Key -Name $d.Name -Type MultiString -Value ([string[]]$d.Paths) }
return 'changed'
}
'SettingsRecord' {
# Install only: -Uninstall deletes the whole key in its DenyPathRecord step.
$changed = $false
if (-not (Test-Path -LiteralPath $d.Key)) { New-Item -Path $d.Key -Force | Out-Null; $changed = $true }
$current = Get-ItemProperty -LiteralPath $d.Key -ErrorAction SilentlyContinue
foreach ($name in @($d.Settings.Keys)) {
$want = $d.Settings[$name]
$have = if ($current -and $current.PSObject.Properties[$name]) { $current.$name } else { $null }
if ($null -ne $have -and "$have" -ceq "$want") { continue }
$kind = if ($want -is [int]) { 'DWord' } else { 'String' }
Set-ItemProperty -LiteralPath $d.Key -Name $name -Type $kind -Value $want
$changed = $true
}
if (-not (Test-OtOpcUaRecordKeyAcl -Key $d.Key -Sddl $d.Sddl)) {
$acl = Get-Acl -Path $d.Key
$acl.SetSecurityDescriptorSddlForm($d.Sddl, 'Access')
Set-Acl -Path $d.Key -AclObject $acl
$changed = $true
}
return $(if ($changed) { 'changed' } else { 'unchanged' })
}
'FirewallBlockOutbound' {
$rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
if ($d.ContainsKey('Remove')) {
@@ -433,6 +479,109 @@ function Get-OtOpcUaRecordedDenyPaths {
if ($v) { @($v.$Name | Where-Object { $_ }) }
}
function Test-OtOpcUaRecordKeyAcl {
# True when the record key's DACL is exactly $Sddl (protected; SYSTEM + Administrators), compared in canonical form.
param([string]$Key, [string]$Sddl = $script:RecordKeySddl)
$want = [System.Security.AccessControl.RegistrySecurity]::new()
$want.SetSecurityDescriptorSddlForm($Sddl, 'Access')
# -Path, not -LiteralPath: Windows PowerShell 5.1's Get-Acl -LiteralPath does not find registry keys (windev, #901).
(Get-Acl -Path $Key).GetSecurityDescriptorSddlForm('Access') -ceq $want.GetSecurityDescriptorSddlForm('Access')
}
function Get-OtOpcUaIsolationSettings {
<#
.SYNOPSIS
The settings an earlier install recorded (lmxopcua#901) as a hashtable: InstallRoot, HostServiceAccount, PeCacheDir,
HostServiceName, ConfigureHostService ([bool]) and DenyPaths. $null when the key or InstallRoot/HostServiceAccount
is missing (nothing installed, or installed by an installer older than #901, which recorded only DenyPaths).
#>
param([string]$WorkerServiceName = 'OtOpcUaScriptWorker')
$key = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
if (-not (Test-Path -LiteralPath $key)) { return $null }
$v = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue
if (-not $v) { return $null }
$get = { param($n) if ($v.PSObject.Properties[$n]) { $v.$n } else { $null } }
$root = & $get 'InstallRoot'; $account = & $get 'HostServiceAccount'
if (-not $root -or -not $account) { return $null }
@{
InstallRoot = [string]$root
HostServiceAccount = [string]$account
PeCacheDir = [string](& $get 'PeCacheDir')
HostServiceName = $(if (& $get 'HostServiceName') { [string](& $get 'HostServiceName') } else { 'OtOpcUaHost' })
ConfigureHostService = [bool][int](& $get 'ConfigureHostService')
DenyPaths = @(& $get $script:DenyRecordValue | Where-Object { $_ })
}
}
function Test-ScriptWorkerIsolationStep {
<#
.SYNOPSIS
Checks one INSTALL plan step against the machine without changing anything (lmxopcua#901, -Verify). Returns
[pscustomobject]@{ Ok; Detail }. Windows only.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] $Step)
$d = $Step.Data
$result = { param([bool]$Ok, [string]$Detail) [pscustomobject]@{ Ok = $Ok; Detail = $Detail } }
switch ($Step.Id) {
'AnchorService' {
$cfg = Get-CimInstance Win32_Service -Filter "Name='$($d.Name)'" -ErrorAction SilentlyContinue
if (-not $cfg) { return (& $result $false "service '$($d.Name)' not found") }
$ok = $cfg.StartMode -eq 'Disabled' -and $cfg.StartName -ieq $d.Identity -and $cfg.PathName -eq "`"$($d.BinPath)`""
return (& $result $ok "StartMode=$($cfg.StartMode) StartName=$($cfg.StartName) PathName=$($cfg.PathName)")
}
'DenyPathRecord' {
$current = @(Get-OtOpcUaRecordedDenyPaths -Key $d.Key -Name $d.Name)
$ok = (@($current) -join "`n") -ceq (@($d.Paths) -join "`n")
return (& $result $ok "recorded: $(if ($current.Count) { $current -join '; ' } else { 'none' })")
}
'SettingsRecord' {
if (-not (Test-Path -LiteralPath $d.Key)) { return (& $result $false 'record key missing') }
$v = Get-ItemProperty -LiteralPath $d.Key
$bad = @(foreach ($n in @($d.Settings.Keys)) {
if (-not $v.PSObject.Properties[$n] -or "$($v.$n)" -cne "$($d.Settings[$n])") { $n }
})
$aclOk = Test-OtOpcUaRecordKeyAcl -Key $d.Key -Sddl $d.Sddl
if (-not $aclOk) { $bad += 'key ACL (want SYSTEM + Administrators only, protected)' }
return (& $result ($bad.Count -eq 0) $(if ($bad.Count) { "differs: $($bad -join ', ')" } else { 'settings recorded, key locked' }))
}
'PeCacheDir' {
if (-not (Test-Path -LiteralPath $d.Path)) { return (& $result $false 'missing') }
$desired = [System.Security.AccessControl.DirectorySecurity]::new()
$desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner')
$current = (Get-Acl -LiteralPath $d.Path).GetSecurityDescriptorSddlForm('Access, Owner')
return (& $result ($current -eq $desired.GetSecurityDescriptorSddlForm('Access, Owner')) "SDDL $current")
}
{ $_ -in 'PeEntriesDir', 'WorkerFolderAcl', 'InstallRootDeny', 'SensitiveDirDeny' } {
if (-not (Test-Path -LiteralPath $d.Path)) {
if ($d.ContainsKey('Remove')) { return (& $result $true 'absent') }
return (& $result $false 'folder missing')
}
$state = if ($d.ContainsKey('Remove')) { Get-OtOpcUaExplicitAceState -Path $d.Path -Sid $d.Sid -Remove }
else { Get-OtOpcUaExplicitAceState -Path $d.Path -Sid $d.Sid -Mask $d.Mask -Type $d.Type }
$want = if ($d.ContainsKey('Remove')) { 'none' } else { '{0} 0x{1:x} ContainerInherit, ObjectInherit' -f $d.Type, $d.Mask }
return (& $result $state.Same "explicit worker ACE: $($state.Found) (want $want)")
}
'FirewallBlockOutbound' {
$rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
if (-not $rule) { return (& $result $false "rule '$($d.Name)' missing") }
$app = ($rule | Get-NetFirewallApplicationFilter).Program
$ok = "$($rule.Direction)" -eq 'Outbound' -and "$($rule.Action)" -eq 'Block' -and "$($rule.Enabled)" -eq 'True' -and
"$($rule.Profile)" -eq 'Any' -and $app -ieq $d.Program
if ($ok -and -not (Test-Path -LiteralPath $d.Program)) { return (& $result $false "rule ok but $($d.Program) is missing") }
return (& $result $ok "$($rule.Direction)/$($rule.Action)/$($rule.Profile)/enabled=$($rule.Enabled) program=$app")
}
'HostServiceEnv' {
$key = "HKLM:\SYSTEM\CurrentControlSet\Services\$($d.Service)"
if (-not (Test-Path $key)) { return (& $result $false "service '$($d.Service)' not installed") }
$current = @((Get-ItemProperty -Path $key -Name Environment -ErrorAction SilentlyContinue).Environment | Where-Object { $_ })
$missing = @(foreach ($k in $d.Set.Keys) { if ($current -notcontains "$k=$($d.Set[$k])") { $k } })
return (& $result ($missing.Count -eq 0) $(if ($missing.Count) { "missing or different: $($missing -join ', ')" } else { 'both entries present' }))
}
default { throw "unknown plan step '$($Step.Id)'" }
}
}
function Find-OtOpcUaDenyCoverageGap {
<#
.SYNOPSIS
@@ -456,4 +605,5 @@ function Find-OtOpcUaDenyCoverageGap {
Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce,
Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Resolve-OtOpcUaDenyPaths, Get-ScriptWorkerIsolationPlan,
Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap
Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap, Get-OtOpcUaIsolationSettings,
Test-ScriptWorkerIsolationStep, Test-OtOpcUaRecordKeyAcl