9999 machine-number prompt: split into user-context Prompt + SYSTEM-context Apply

OLD design: a single 'Check Machine Number' scheduled task ran as the
logged-in user (BUILTIN\Users, Limited) on AtLogOn. It both showed the
InputBox AND tried to update HKLM\SOFTWARE\WOW6432Node\GE Aircraft
Engines\DNC\General + C:\ProgramData\UDC\udc_settings.json. To make
those non-admin writes possible, 02-MachineNumberACLs.ps1 pre-granted
BUILTIN\Users SetValue + Modify on those targets during imaging.

Three problems with that:
  1. SECURITY: any logged-in user could overwrite the machine-identity
     reg key.
  2. FRAGILE: ACL grants raced with eDNC install timing on some bays
     (eDNC reg key didn't exist yet when 02-MachineNumberACLs ran;
     OpenSubKey returned null, ACL silently skipped, Check-MachineNumber
     later failed with PermissionDenied).
  3. SILENT-SUCCESS BUG: Update-MachineNumber's Set-ItemProperty calls
     lacked -ErrorAction Stop. PermissionDenied is a non-terminating
     error in PS5.1, so the try/catch never fired. The script set
     $out.EdncUpdated=$true anyway and the dialog reported success
     while the reg value stayed at 9999. WJF capture log on FGY07FZ3
     shows this exact pattern.

NEW design - two scheduled tasks split by responsibility:

  - "Prompt Machine Number" : AtLogOn trigger, BUILTIN\Users (Limited).
    Reads current values (read-only). If 9999, shows InputBox. Writes
    typed number to C:\Logs\SFLD\machine-number-request.txt. Triggers
    SYSTEM Apply via schtasks /run. Polls for result JSON (60s timeout).
    Shows result MessageBox with TopMost so it isn't hidden behind
    other windows.

  - "Apply Machine Number" : on-demand, SYSTEM (Highest). Reads the
    request file, calls Update-MachineNumber (full HKLM + ProgramData
    access from SYSTEM context). Pulls per-machine NTLARS .reg + UDC
    settings JSON + UDC live data from the SFLD share if site-config
    has share paths. Writes result JSON. Removes request file.
    Unregisters the Prompt task on full success (Prompt itself can't
    self-unregister - Limited users can't delete a SYSTEM-owned task).

  - Default task SDDL only allows Admins + SYSTEM to read/run a
    SYSTEM-owned task. Added BUILTIN\Users GR+GX ACE via COM
    SetSecurityDescriptor so the Limited Prompt task can schtasks /run
    Apply on demand. They can read + execute it; not modify or delete.

  - Update-MachineNumber.ps1 writes now have -ErrorAction Stop so
    PermissionDenied actually fires the catch block instead of being
    swallowed.

  - 02-MachineNumberACLs.ps1 gutted to a no-op (left in place for
    Stage-Dispatcher discovery; no longer grants the ACLs). Old bays'
    existing grants are harmless since SYSTEM ignores them.

  - Register-CheckMachineNumberTask.ps1 now installs both tasks AND
    unregisters the legacy 'Check Machine Number' task name on
    re-imaging. Run-ShopfloorSetup.ps1's $skipInBaseline list now
    includes Prompt-MachineNumber.ps1 + Apply-MachineNumber.ps1 so
    they aren't auto-run during the baseline pass (only via the
    scheduled tasks).

Smoke tested end-to-end on win11 VM with ShopFloor (Limited) logging in
interactively: AtLogOn trigger fired Prompt, dialog rendered, tech
typed 7777, schtasks /run succeeded (the SDDL fix lets Limited users
trigger SYSTEM tasks), Apply ran as SYSTEM, eDNC reg + machine-number.txt
both updated to 7777, result MessageBox shown, Prompt task auto-
unregistered by Apply's cleanup step. No ACL grants needed on any user.

Apply also re-tested with -ErrorAction Stop confirming non-terminating
PermissionDenied now properly throws into the catch + populates Errors[]
+ flips $out.EdncUpdated to false - so any future write failures will
report honestly instead of silently claiming success.
This commit is contained in:
cproudlock
2026-05-24 17:08:59 -04:00
parent de7d41f5e5
commit 7298d433eb
7 changed files with 471 additions and 136 deletions

View File

@@ -1,17 +1,27 @@
# Register-CheckMachineNumberTask.ps1 - Register the "Check Machine Number"
# logon scheduled task at imaging time. Mirrors Register-MapSfldShare.ps1.
# Register-CheckMachineNumberTask.ps1 - Register the two-task machine
# number flow at imaging time:
#
# The task fires at every interactive logon for BUILTIN\Users (so the
# ShopFloor end-user, who is the auto-logon principal post-lockdown,
# triggers it). Check-MachineNumber.ps1 reads the current UDC + eDNC
# machine numbers, and:
# - If neither is 9999, unregisters the task and exits (one-shot).
# - If either is 9999, pops an InputBox forcing the user to type the
# real number; on success calls Update-MachineNumber.ps1 which pulls
# the per-machine NTLARS .reg + UDC settings JSON + UDC data backup
# from the SFLD share and applies them.
# 1. "Prompt Machine Number" - AtLogOn, BUILTIN\Users, Limited.
# Shows InputBox + writes new number to a request file, then triggers
# the SYSTEM task via schtasks /run.
#
# Idempotent: safe to re-run. Existing task is overwritten.
# 2. "Apply Machine Number" - on-demand only (no trigger), SYSTEM,
# RunLevel Highest. Reads the request file, calls Update-MachineNumber
# with full HKLM + ProgramData access, writes a result JSON, removes
# the request file. No GUI - the Prompt task polls the result file
# and displays the dialog.
#
# Replaces the old single-task design that ran as the logged-in user with
# pre-granted BUILTIN\Users HKLM ACLs (02-MachineNumberACLs.ps1). That
# approach was fragile (timing race with eDNC install, silent ACL skip)
# and a security hole (any user could write to the machine-identity reg
# key). With SYSTEM doing the actual writes, no ACL grants needed.
#
# Idempotent: safe to re-run. Existing tasks are overwritten.
#
# File kept named Register-CheckMachineNumberTask.ps1 (rather than
# Register-MachineNumberTasks.ps1) so Run-ShopfloorSetup's existing
# discovery doesn't need editing.
$ErrorActionPreference = 'Continue'
@@ -28,9 +38,19 @@ function Write-RegLog {
Write-RegLog '=== Register-CheckMachineNumberTask start ==='
$taskName = 'Check Machine Number'
$promptTaskName = 'Prompt Machine Number'
$applyTaskName = 'Apply Machine Number'
$oldTaskName = 'Check Machine Number' # legacy, removed below
# Only arm the task if the bay was imaged with the 9999 placeholder. If
# Clean up the legacy single-task name from prior imaging cycles.
try {
if (Get-ScheduledTask -TaskName $oldTaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $oldTaskName -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered legacy task '$oldTaskName'"
}
} catch { Write-RegLog "Could not unregister legacy '$oldTaskName': $_" }
# Only arm the tasks if the bay was imaged with the 9999 placeholder. If
# the tech entered a real machine number during PXE imaging it's already
# in C:\Enrollment\machine-number.txt; no prompt needed on first logon.
$mnFile = 'C:\Enrollment\machine-number.txt'
@@ -41,63 +61,87 @@ if (Test-Path -LiteralPath $mnFile) {
}
Write-RegLog "Imaging-time machine-number.txt = '$mnAtImaging'"
if ($mnAtImaging -ne '9999') {
Write-RegLog "Machine number is real ('$mnAtImaging' != 9999). Not registering task."
# Clean up any stale task from a prior 9999-imaging cycle on the same disk.
try {
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered stale task '$taskName'"
}
} catch {}
Write-RegLog "Machine number is real ('$mnAtImaging' != 9999). Not registering tasks."
foreach ($t in @($promptTaskName, $applyTaskName)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered stale task '$t'"
}
} catch {}
}
Write-RegLog '=== Register-CheckMachineNumberTask end (no-op) ==='
exit 0
}
# Resolve the script path. Prefer the staged shopfloor-setup tree on C:
# Resolve script paths. Prefer the staged shopfloor-setup tree on C:
# (where Run-ShopfloorSetup ran from); fall back to the same dir as this
# Register script if invoked standalone.
$checkScript = Join-Path $PSScriptRoot 'Check-MachineNumber.ps1'
if (-not (Test-Path -LiteralPath $checkScript)) {
$checkScript = 'C:\Enrollment\shopfloor-setup\Shopfloor\Check-MachineNumber.ps1'
function Resolve-Script {
param([string]$LeafName)
$p = Join-Path $PSScriptRoot $LeafName
if (Test-Path -LiteralPath $p) { return $p }
$p = "C:\Enrollment\shopfloor-setup\Shopfloor\$LeafName"
if (Test-Path -LiteralPath $p) { return $p }
return $null
}
if (-not (Test-Path -LiteralPath $checkScript)) {
Write-RegLog "Check-MachineNumber.ps1 not found at $checkScript - cannot register"
exit 1
}
Write-RegLog "Check-MachineNumber.ps1 at: $checkScript"
$promptScript = Resolve-Script 'Prompt-MachineNumber.ps1'
$applyScript = Resolve-Script 'Apply-MachineNumber.ps1'
if (-not $promptScript) { Write-RegLog "Prompt-MachineNumber.ps1 not found - cannot register"; exit 1 }
if (-not $applyScript) { Write-RegLog "Apply-MachineNumber.ps1 not found - cannot register"; exit 1 }
Write-RegLog "Prompt script: $promptScript"
Write-RegLog "Apply script: $applyScript"
# --- Prompt task (user-context, GUI) ---
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Normal -File `"$checkScript`""
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Normal -File `"$promptScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Run as the logged-in user (needs GUI for InputBox), NOT SYSTEM.
# Group SID S-1-5-32-545 = BUILTIN\Users; catches ShopFloor + any
# support / admin user that logs in interactively.
$principal = New-ScheduledTaskPrincipal `
-GroupId 'S-1-5-32-545' `
-RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Force `
-ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$taskName' (AtLogOn, BUILTIN\Users, Limited)"
# Group SID S-1-5-32-545 = BUILTIN\Users (catches ShopFloor + support/admin
# users that log in interactively). RunLevel Limited - no elevation; the
# actual writes happen in the SYSTEM Apply task below.
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask -TaskName $promptTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$promptTaskName' (AtLogOn, BUILTIN\Users, Limited)"
} catch {
Write-RegLog "FAILED to register '$taskName': $_"
Write-RegLog "FAILED to register '$promptTaskName': $_"
exit 1
}
# --- Apply task (SYSTEM, on-demand) ---
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$applyScript`""
# No trigger - the Prompt task starts this via schtasks /run /tn.
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 10)
Register-ScheduledTask -TaskName $applyTaskName -Action $action -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$applyTaskName' (on-demand, SYSTEM, Highest)"
# Default SDDL on a SYSTEM-owned task only grants Admins + SYSTEM
# FullAccess - BUILTIN\Users can't see or run it via schtasks /run.
# Add an ACE granting BUILTIN\Users GenericRead + GenericExecute so the
# user-context Prompt task can trigger this Apply task on demand. They
# still can't modify/delete it - only read+execute.
try {
$svc = New-Object -ComObject Schedule.Service
$svc.Connect()
$taskObj = $svc.GetFolder('\').GetTask($applyTaskName)
# GenericRead = 0x80000000 (GR), GenericExecute = 0x20000000 (GX)
# BU = BUILTIN\Users
$newSd = 'O:BAG:BAD:(A;;FA;;;BA)(A;;FA;;;SY)(A;;GRGX;;;BU)'
# SetSecurityDescriptor flag 0 = default, persists DACL change.
$taskObj.SetSecurityDescriptor($newSd, 0)
Write-RegLog "Granted BUILTIN\Users GR+GX on '$applyTaskName' (so Limited users can schtasks /run)"
} catch {
Write-RegLog "FAILED to set task SDDL on '$applyTaskName': $_ (Limited users may not be able to trigger Apply)"
}
} catch {
Write-RegLog "FAILED to register '$applyTaskName': $_"
exit 1
}