Skip to content

Verify the data directory the way the service will, before starting it - #187

Merged
Sev7eNup merged 1 commit into
mainfrom
fix/installer-data-dir-acl-verify
Aug 11, 2026
Merged

Verify the data directory the way the service will, before starting it#187
Sev7eNup merged 1 commit into
mainfrom
fix/installer-data-dir-acl-verify

Conversation

@Sev7eNup

Copy link
Copy Markdown
Owner

Why

A 1.2.3 install failed at the first service start and rolled back:

JWT signing-key file security validation failed: parent directory C:\ProgramData\NodePilot grants mutation rights to an untrusted principal.

The check is not newgit log -S puts it in the 1.0.0 commit — and git diff v1.2.2..v1.2.3 touches nothing outside the LLM stack, docs and version numbers. It was never a version problem.

The installer already applied the correct ACL. What it never did was verify the result. The trusted set the service evaluates against (BuildTrustedSids) includes the identity it is running as, so an ACE for a service account is only harmless while the service runs as that account. Install once as A and again as B, and A's leftover ACE is a stranger with write access next to the signing key. Nothing between Set-DirectoryAclForService and Start-Service noticed, so the API discovered it — after the binaries had been replaced, which lands the failure in the rollback path that the catch block already documents as able to fail on its own. That exact scenario was reproduced in the lab on 2026-08-05 and written down; the guard that would have caught it was never added.

What

Test-ServiceDirectoryAclTrust answers the same question the service will, the same way: trusted owner, no foreign Allow-ACE carrying mutation rights. The trusted set is kept as SIDs, not names — a localised Windows calls these groups something else, and a domain account resolves differently — and a contract test pins that it matches the API's set, because an installer that blesses a directory the service then rejects is the precise failure this exists for.

Assert-ServiceDirectoryAclUsable verifies → repairs once through the same Set-DirectoryAclForService (which drops inheritance, wipes explicit ACEs and forces the owner back to Administrators) → verifies again → otherwise gives up naming the principal and the icacls command that removes it. It runs before the artifact is extracted, so giving up costs nothing.

RestrictedFileWriter names the offender. "grants mutation rights to an untrusted principal" is true and unusable. It is now …to DOMAIN\account (S-1-5-21-…), or the bare SID with "account no longer exists" — the common case here, since a decommissioned service account keeps its ACE, and the SID is what icacls takes.

Deliberately not done

  • The security check is unchanged. Widening the trusted set to bless any configured service account would defeat an audit finding whose whole point is a foreign account with write access beside the JWT signing key. Producing a clean directory reaches the same outcome without giving that up.
  • No new readiness-page row. The shared Invoke-NodePilotPreflight is the natural home for surfacing this earlier in the wizard, but it would pull in the fixed-size CheckIds array, CheckCount and their contract tests. The installer-side verification already guarantees the install succeeds; the wizard row is a separate, larger change.

Verification

  • Exercised the real functions against a real directory: a fresh directory under a permissive parent is correctly reported insecure; after Set-DirectoryAclForService it is secure; an injected BUILTIN\Users:(M) ACE is detected and named (VORDEFINIERT\Benutzer (S-1-5-32-545) on this German host — which is exactly why the trusted set is SID-based); Assert-ServiceDirectoryAclUsable repairs it and the re-check passes.
  • deploy/Test-DeploymentTemplates.ps1 green, with four new contracts: the verification exists and follows the ACL apply, it runs before extraction, the repair is re-verified and gives up loudly, and the trusted SIDs match the API's.
  • Full backend suite green: 5414 tests, including a new one pinning that the rejection names the principal by SID rather than by a localised display name.
  • Documented in the deployment guide's troubleshooting table — including that Jwt:RotateInsecureKeyFile=true, which the error message suggests, does not help: it replaces the key file, and the directory is what was rejected.

A 1.2.3 install failed at the first service start with "JWT signing-key file
security validation failed: parent directory 'C:\ProgramData\NodePilot' grants
mutation rights to an untrusted principal", and rolled back.

The check is not new — it has existed since 1.0.0 — and the installer already
applied the right ACL. What it never did was verify the result. The trusted set
the service evaluates against includes the identity it is RUNNING as, so an ACE
for a service account is only harmless while the service runs as that account.
Install once as A and again as B and A's leftover ACE is a stranger with write
access next to the signing key. Nothing between the ACL call and Start-Service
noticed, so the API discovered it — after the binaries had been replaced, which
puts the failure in the rollback path the catch block already documents as able
to fail on its own.

The installer now asks the same question itself. Test-ServiceDirectoryAclTrust
mirrors BuildTrustedSids (as SIDs, not names — a localised Windows calls these
groups something else), and Assert-ServiceDirectoryAclUsable verifies, repairs
once through the same Set-DirectoryAclForService, verifies again, and otherwise
gives up naming the principal and the icacls command that removes it. It runs
before the artifact is extracted, so giving up costs nothing.

The security check itself is unchanged. Widening the trusted set to bless any
configured service account would defeat an audit finding whose whole point is a
foreign account with write access beside the JWT key; producing a clean
directory achieves the same outcome without giving that up.

RestrictedFileWriter now names the offending principal instead of reporting an
anonymous "untrusted principal" — account name and SID where it resolves, the
bare SID where the account is gone, which is the common case here and exactly
what icacls needs.
AccessControlType.Allow));
dir.SetAccessControl(acl);

var reason = ValidateParentDirectoryReason(Path.Combine(_tempDir, "jwt.key"));
@Sev7eNup
Sev7eNup merged commit 7c99455 into main Aug 11, 2026
8 checks passed
@Sev7eNup
Sev7eNup deleted the fix/installer-data-dir-acl-verify branch August 11, 2026 23:06
Sev7eNup added a commit that referenced this pull request Aug 11, 2026
Carries the installer fix (#187): the data directory is verified with the
service's own rule after the ACL is applied, repaired once if a leftover ACE
from an earlier service identity is in the way, and only then does the service
start — instead of the API discovering it at first start, after the binaries
have been replaced.

Bumped through `npm version --no-git-tag-version` per package rather than a
text replace over the tree, so only the top-level version and its lockfile root
entry move.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants