From a61e6374116f27af66c76823ea559c8494887d43 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 19:15:13 -0400 Subject: [PATCH 1/2] Gitignore .local/ directory for dev-only secrets like the Galaxy.Host shared secret. Created during the PR 38 / install-services workflow to keep per-install secrets out of the repo. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c23064a..580c14c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ packages/ # Claude Code (per-developer settings, runtime lock files, agent transcripts) .claude/ +.local/ -- 2.49.1 From 8fb3dbe53b6e184bd96d377e817ed612be432b2c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 19:17:43 -0400 Subject: [PATCH 2/2] =?UTF-8?q?Phase=203=20PR=2039=20=E2=80=94=20LiveStack?= =?UTF-8?q?Fixture=20pre-flight=20detect=20for=20elevated=20shell.=20The?= =?UTF-8?q?=20OtOpcUaGalaxyHost=20named-pipe=20ACL=20allows=20the=20config?= =?UTF-8?q?ured=20SID=20but=20explicitly=20DENIES=20Administrators=20per?= =?UTF-8?q?=20decision=20#76=20/=20PipeAcl.cs=20(production-hardening=20?= =?UTF-8?q?=E2=80=94=20keeps=20an=20admin=20shell=20on=20a=20deployed=20bo?= =?UTF-8?q?x=20from=20connecting=20to=20the=20IPC=20channel=20without=20go?= =?UTF-8?q?ing=20through=20the=20configured=20service=20principal).=20A=20?= =?UTF-8?q?test=20process=20running=20with=20a=20high-integrity=20elevated?= =?UTF-8?q?=20token=20carries=20the=20Administrators=20group=20in=20its=20?= =?UTF-8?q?security=20context=20regardless=20of=20whose=20user=20it=20'is'?= =?UTF-8?q?,=20so=20the=20deny=20rule=20trumps=20the=20user's=20allow=20an?= =?UTF-8?q?d=20the=20pipe=20connect=20returns=20UnauthorizedAccessExceptio?= =?UTF-8?q?n=20at=20the=20prerequisite-probe=20stage.=20Functionally=20cor?= =?UTF-8?q?rect=20but=20operationally=20confusing=20=E2=80=94=20when=20thi?= =?UTF-8?q?s=20hit=20during=20the=20PR=2038=20install=20workflow=20it=20to?= =?UTF-8?q?ok=20five=20steps=20to=20diagnose=20('the=20user=20IS=20in=20th?= =?UTF-8?q?e=20allow=20list,=20why=20is=20the=20pipe=20denying=20access=3F?= =?UTF-8?q?').=20The=20pre-existing=20ParityFixture=20(PR=2018)=20already?= =?UTF-8?q?=20documents=20this=20with=20an=20explicit=20early-skip;=20Live?= =?UTF-8?q?StackFixture=20(PR=2037)=20didn't.=20PR=2039=20closes=20the=20g?= =?UTF-8?q?ap.=20New=20IsElevatedAdministratorOnWindows=20static=20helper?= =?UTF-8?q?=20(Windows-only=20via=20RuntimeInformation.IsOSPlatform;=20non?= =?UTF-8?q?-Windows=20hosts=20return=20false=20and=20let=20the=20prerequis?= =?UTF-8?q?ite=20probe=20own=20the=20skip-with-reason=20path)=20checks=20W?= =?UTF-8?q?indowsPrincipal.IsInRole(WindowsBuiltInRole.Administrator)=20on?= =?UTF-8?q?=20the=20current=20process=20token.=20When=20true,=20Initialize?= =?UTF-8?q?Async=20short-circuits=20to=20a=20SkipReason=20that=20names=20t?= =?UTF-8?q?he=20cause=20directly:=20'elevated=20token's=20Admins=20group?= =?UTF-8?q?=20membership=20trumps=20the=20allow=20rule=20=E2=80=94=20re-ru?= =?UTF-8?q?n=20from=20a=20NORMAL=20(non-admin)=20PowerShell=20window'.=20C?= =?UTF-8?q?atches=20and=20swallows=20any=20probe-side=20exception=20so=20a?= =?UTF-8?q?=20Win32=20oddity=20can't=20crash=20the=20test=20fixture;=20fai?= =?UTF-8?q?led=20probe=20falls=20through=20to=20the=20regular=20prerequisi?= =?UTF-8?q?te=20path.=20The=20check=20fires=20BEFORE=20AvevaPrerequisites.?= =?UTF-8?q?CheckAllAsync=20runs=20because=20the=20prereq=20probe's=20own?= =?UTF-8?q?=20pipe=20connect=20hits=20the=20same=20admin-deny=20and=20surf?= =?UTF-8?q?aces=20UnauthorizedAccessException=20with=20no=20context.=20Sho?= =?UTF-8?q?rt-circuiting=20earlier=20saves=20the=2010-second=20probe=20+?= =?UTF-8?q?=20produces=20a=20single=20actionable=20line.=20Tests=20?= =?UTF-8?q?=E2=80=94=20verified=20manually=20from=20an=20elevated=20bash?= =?UTF-8?q?=20session=20against=20the=20just-installed=20OtOpcUaGalaxyHost?= =?UTF-8?q?=20service:=20skip=20message=20reads=20'Test=20host=20is=20runn?= =?UTF-8?q?ing=20with=20elevated=20(Administrators)=20privileges,=20but=20?= =?UTF-8?q?the=20OtOpcUaGalaxyHost=20named-pipe=20ACL=20explicitly=20denie?= =?UTF-8?q?s=20Administrators=20per=20the=20IPC=20security=20design=20(dec?= =?UTF-8?q?ision=20#76=20/=20PipeAcl.cs).=20Re-run=20from=20a=20NORMAL=20(?= =?UTF-8?q?non-admin)=20PowerShell=20window=20=E2=80=94=20even=20when=20yo?= =?UTF-8?q?ur=20user=20is=20already=20in=20the=20pipe's=20allow=20list,=20?= =?UTF-8?q?the=20elevated=20token's=20Admins=20group=20membership=20trumps?= =?UTF-8?q?=20the=20allow=20rule.'=20Proxy.Tests=20Unit:=2017=20pass=20/?= =?UTF-8?q?=200=20fail=20(unchanged=20=E2=80=94=20fixture=20change=20is=20?= =?UTF-8?q?non-breaking;=20existing=20tests=20don't=20run=20as=20admin=20i?= =?UTF-8?q?n=20normal=20CI=20flow).=20Build=20clean.=20Bonus:=20gitignored?= =?UTF-8?q?=20.local/=20directory=20(a=20previous=20direct=20commit=20on?= =?UTF-8?q?=20local=20v2=20that=20I'm=20now=20landing=20here)=20so=20per-i?= =?UTF-8?q?nstall=20secrets=20like=20the=20Galaxy.Host=20shared-secret=20f?= =?UTF-8?q?ile=20don't=20leak=20into=20the=20repo.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../LiveStack/LiveStackFixture.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs index 7199c22..2915811 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackFixture.cs @@ -1,3 +1,6 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Principal; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -40,6 +43,25 @@ public sealed class LiveStackFixture : IAsyncLifetime public async ValueTask InitializeAsync() { + // 0. Elevated-shell short-circuit. The OtOpcUaGalaxyHost pipe ACL allows the configured + // SID but explicitly DENIES Administrators (decision #76 — production hardening). + // A test process running with a high-integrity token (any elevated shell) carries the + // Admins group in its security context, so the deny rule trumps the user's allow and + // the pipe connect returns UnauthorizedAccessException — technically correct but + // the operationally confusing failure mode that ate most of the PR 37 install + // debugging session. Surfacing it explicitly here saves the next operator the same + // five-step diagnosis. ParityFixture has the same skip with the same rationale. + if (IsElevatedAdministratorOnWindows()) + { + SkipReason = + "Test host is running with elevated (Administrators) privileges, but the " + + "OtOpcUaGalaxyHost named-pipe ACL explicitly denies Administrators per the IPC " + + "security design (decision #76 / PipeAcl.cs). Re-run from a NORMAL (non-admin) " + + "PowerShell window — even when your user is already in the pipe's allow list, " + + "the elevated token's Admins group membership trumps the allow rule."; + return; + } + // 1. AVEVA + OtOpcUa service state — actionable diagnostic if anything is missing. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); PrerequisiteReport = await AvevaPrerequisites.CheckAllAsync( @@ -111,6 +133,28 @@ public sealed class LiveStackFixture : IAsyncLifetime { if (SkipReason is not null) Assert.Skip(SkipReason); } + + private static bool IsElevatedAdministratorOnWindows() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return false; + return CheckWindowsAdminToken(); + } + + [SupportedOSPlatform("windows")] + private static bool CheckWindowsAdminToken() + { + try + { + using var identity = WindowsIdentity.GetCurrent(); + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + } + catch + { + // Probe shouldn't crash the test; if we can't determine elevation, optimistically + // continue and let the actual pipe connect surface its own error. + return false; + } + } } [CollectionDefinition(Name)] -- 2.49.1