Skip to content

Install scripts: the service account gets Read & Execute on the install root and Modify only where it writes (#4052) - #4090

Merged
erikdarlingdata merged 8 commits into
devfrom
fix/4052-narrow-service-modify
Sep 24, 2026
Merged

erikdarlingdata merged 8 commits into
devfrom
fix/4052-narrow-service-modify

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

This is part of #4052: the install scripts narrow the Darling service account's rights on its install tree. The service-side prep, #4097 (the service empties pg-runtime-prev in place instead of deleting and recreating it), merged first, and this branch now contains it (merge a4583d1). The box run showed the runtime upgrade fails without it.

Part of #4052 (worded "Part of" on purpose: the merge watcher closes any issue a merged body says it closes).

What changed

Lock-DarlingInstallTree changes the same way in install-darling.ps1 and upgrade-darling.ps1; the function text stays byte-identical across the two scripts.

  • The install root: the service account gets /grant:r Read & Execute. The :r matters: Install and upgrade lock the Darling install folder, so an ordinary local user can no longer replace the service's binaries (#4034) #4038's lock left an explicit (OI)(CI)M ACE for the service on upgraded installs, and a plain /grant only adds to it.
  • pg-runtime\ and pg-runtime-prev\: the lock creates them if missing and grants the service Modify on each.
  • darling-keys\, the bring-your-own-PostgreSQL key folder (DarlingLogHashKeyFile.BringYourOwnDirectoryName), gets the same Modify grant. It applies only when darling.json sets postgres.managed = false and sits in the install root. The new Get-DarlingExtraServiceWriteDirectories works this out, and every caller wires it in.
  • The verify-and-close walk trusts the service only where it may write: the root itself (read-only now), the service-write folders, and darling.json and its backups. Step 4b gives the service an explicit FullControl on those files by design (Security: darling.json holds recoverable secrets and is never ACL-hardened #1647). A write grant the service holds anywhere else is closed like a stranger's. A service-write folder gets its Modify back right after its ACL is reset.

Where the service writes under the install root

Path Service writes? Grant after this PR
pg-runtime\ (the runtime extract, the pg-runtime.sha256/.stamp/.blocked stamps, pgsql.failed) Yes (DarlingManagedPostgres, DarlingStoreUpgrade) Modify
pg-runtime-prev\ (the rescued runtime) Yes; emptied in place since #4097 Modify
darling-keys\ (bring-your-own PostgreSQL, outside a container) Yes (DarlingLogHashKeyFile) Modify, when it applies
darling.json, darling.json.bak-* By step 4b's design (#1647) FullControl, unchanged
wwwroot\ No: UseStaticFiles only; no filesystem write API under the web root RX
Everything else (exe, DLLs, scripts) No RX

Found by the coordinator, verified on Windows

Before this was pushed, the coordinator ran the lock as extracted from the scripts on PowerShell 5.1, on a standalone Windows 11 VM. It used a real, untrusted service account (NETWORK SERVICE), darling.json and a backup carrying step 4b's ACL, and #4038's leftover root ACE. That run found three defects in the earlier commits, fixed in d3b1815:

  1. The narrowed trust stripped the service's own FullControl from darling.json and its backup, through the walk's explicit-ACE branch. The service could not have read its config. The run also reported both files as owned by a stranger. The live lock test runs as TrustedInstaller, which is trusted everywhere, so it cannot see this.
  2. The helper returned a rooted path, and the lock Join-Paths it onto the root. Measured: Join-Path 'C:\a' 'C:\a\b' = C:\a\C:\a\b.
  3. Split-Path -LiteralPath -Parent is an ambiguous parameter set on 5.1, so it threw on every bring-your-own-PostgreSQL install.

The same run on the fixed functions, two passes (the second is an upgrade re-run):

  • nothing was reported open on either pass;
  • the service holds RX on the root (the leftover M is gone) and inherited RX on svc.exe and wwwroot;
  • it holds Modify on pg-runtime, pg-runtime-prev and darling-keys, and FullControl on darling.json and its backup;
  • the root stays protected;
  • the helper returns darling-keys only for managed: false with the config in the root.

A new static pin, TheNarrowedLock_KeepsTheServiceOnItsConfig_AndHandsTheKeyFolderOverAsAName, covers all three defects.

Tests

  • DarlingInstallLocationTests on macOS: no new failures against dev. The class's PowerShell-executing tests can't run there (no powershell.exe), so they run in Windows CI. Its live lock test now expects serviceRights=ReadAndExecute, Synchronize on the root, and Modify on the two runtime folders.
  • The full Darling suite on macOS (lane-4052c, before d3b1815): 13,273 tests, 214 failed. That's the same count as dev on the same rig, all in Windows-only classes.

Box run (standalone Windows 11 VM)

CHANGELOG entry

…ecute + Modify on pg-runtime/pg-runtime-prev

Lock-DarlingInstallTree (install-darling.ps1, upgrade-darling.ps1) previously granted the service
account Modify on the whole install tree. It now grants Read & Execute on the root and Modify only
on pg-runtime\, pg-runtime-prev\, and any $extraServiceDirectories entry (created ahead of time if
missing). The verify/close walk trusts the service SID only under those specific paths - a write
grant it holds anywhere else in the tree is treated like a stranger's and closed - and re-grants
Modify on a service write directory immediately after closing a planted ACE there, so the real
grant is never left stripped. Byte-identical between both scripts, as DarlingInstallLocationTests
checks.

Continuation of #4052 (see PR #4090 body for the accepted research this builds on).
…ver as a name, avoid PS 5.1's ambiguous Split-Path

Found on a PowerShell 5.1 run of the lock on a standalone Windows 11 box, with a
real (untrusted) service account and step 4b's darling.json ACL:
- the narrowed trust stripped the service's own FullControl from darling.json
  and its backups (the explicit-ACE branch), so the service could not read its
  config, and reported both files as owned by a stranger;
- Get-DarlingExtraServiceWriteDirectories returned a rooted path, which the
  lock Join-Paths onto the root (C:\a + C:\a\b = C:\a\C:\a\b);
- Split-Path -LiteralPath -Parent is an ambiguous parameter set on 5.1 and
  threw on every bring-your-own-Postgres install.
The live lock test runs as TrustedInstaller (trusted everywhere), so it could
not see the first. A static pin covers all three.
@erikdarlingdata erikdarlingdata changed the title Narrow the Darling service account's Modify grant to pg-runtime/pg-runtime-prev (WIP, incomplete) Install scripts: the service account gets Read & Execute on the install root and Modify only where it writes (#4052) Sep 24, 2026
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Box run phase 1 (install, start, collect, restart): PASS, on a standalone Windows 11 ARM64 VM (x64 build under emulation), from this branch at d3b1815. The coordinator ran it after the worker it dispatched ran out of turns during staging.

Differences from a real release, none of which touches the ACL behaviour under test:

  • The build is a dotnet publish -r win-x64 --self-contained of the service. The release is framework-dependent, so the install ran with -SkipPreflight, which also skips --test-connection.
  • pg-runtime.zip was built on the VM by fetch-pg-runtime.ps1 (PostgreSQL 18.6, TimescaleDB 2.30.1 and 2.28.1, SHA-verified). That needed PowerShell 7 on the VM: the script says #requires -Version 7.0.
  • darling.json came from the sample, with one unreachable placeholder SQL Server target.
Step Result
Install from C:\PerformanceMonitorDarling Refused by #4050's pre-lock check ("NT AUTHORITY\Authenticated Users on C:\PerformanceMonitorDarling … Nothing was installed or changed"). This is correct behaviour, and it's the default shape for a folder directly under C:.
Install from C:\Program Files\PerformanceMonitorDarling Exit 0. The script locked the root before running anything, created the service under the NT SERVICE\PerformanceMonitor Darling virtual account (auto start), restricted darling.json, reconciled the firewall (no ports), and the service is Running.
The service's rights after install root ReadAndExecute; …Service.exe, …Service.dll and wwwroot ReadAndExecute (inherited); pg-runtime and pg-runtime-prev Modify (explicit); darling.json FullControl. BUILTIN\Users has RX on the root; Authenticated Users has nothing.
First start The service extracted the bundled runtime into pg-runtime\pgsql and wrote pg-runtime.sha256/.stamp itself, under its narrowed Modify grant. Then initdb, then "Postgres store ready (schema v139, 138 migration(s) applied)", 71/71 hypertables with compression policies, and least-privilege roles ready. The collection loop reached the placeholder target (connect failed and retried, as expected). 0 access-denied or UnauthorizedAccess lines in 138 log lines.
Restart Running again. "Managed Postgres started", "store ready (schema v139, 0 migration(s) applied)", "collection loop started". 0 denied lines in the 46 new lines.

Also found:

  • Low: the install script still prints "only SYSTEM, Administrators and NT SERVICE\PerformanceMonitor Darling can change what runs from ". After this PR the service can change only the runtime folders. Fix the sentence in this PR.
  • Info: fetch-pg-runtime.ps1 needs PowerShell 7. It's a packaging-time script, so this only matters for a local build.

Not yet run (phase 2), each a separate step on this VM:

On the maintainer's machine after a nightly: a gMSA account, and nested domain admin.

A VM snapshot from before the run is kept (pre-4090-box-run-20260924). The install is left in place for phase 2.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Box run phase 2 (upgrade with a runtime rescue, locked re-run): PASS once dev is merged in. It also showed why #4097 had to come first. Same VM and install as phase 1. The new build's pg-runtime.zip differs by one marker file, so the service takes its runtime-rescue path.

Run Result
Upgrade to this branch as it stood at d3b1815 (no #4097) The narrowed lock applied, and the upgrade script reported success. At start the service logged "The package ships a different Postgres runtime … rescuing the current runtime to …\pg-runtime-prev\pgsql", then "Could not clear the previous runtime at …\pg-runtime-prev … (Access to the path … denied)". The old code deleted pg-runtime-prev (the service holds Modify on the folder itself) and could not recreate it under an RX root. So the runtime advance was skipped and the store kept the old runtime. This is exactly the failure #4097 fixes.
Upgrade to this branch merged with dev (a4583d1, which carries #4097) The upgrade script's lock recreated pg-runtime-prev with the service's Modify grant. The service rescued the old runtime into pg-runtime-prev\pgsql, extracted the new package (its marker file is present in pg-runtime\pgsql), and then "Postgres store ready (schema v140, 1 migration(s) applied)". 0 access-denied lines in the new log. Afterwards the service has RX on the root and exe, Modify on both runtime folders, and FullControl on darling.json.
Locked re-run of install-darling.ps1 on the installed root Accepted: the #4050 pre-lock check lets an already-locked root through. The lock re-applied, and the service is Running.

Not testable on this VM:

  • an install root on a second volume (the VM's only fixed disk is C:);
  • bring-your-own-PostgreSQL mode (needs an external PostgreSQL server). The darling-keys Modify grant itself is proven by the phase-0 harness run in the PR body.

These go to the maintainer's Windows machine after a nightly, along with the gMSA and nested-domain-admin cases.

Merge order: this branch now contains #4097, so it can merge on its own.

@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 24, 2026 01:37
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 24, 2026 01:37
… (the runtime folders), recursing like every real caller

After #4052 the service holds RX on the root and Modify only on pg-runtime and
pg-runtime-prev, so a root-only probe finds nothing untrusted and the test's
premise (without the account > 0) cannot hold. The real checks all run
Get-UntrustedWriteGrantees -Recurse, so the probe does too. Verified on a
Windows 11 VM against the branch's own functions: tree without the account = 2
(the two runtime folders), with it = 0, root alone = 0 (pinned as the #4052
gain).
@erikdarlingdata
erikdarlingdata merged commit c51706c into dev Sep 24, 2026
15 of 16 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4052-narrow-service-modify branch September 24, 2026 01:58
erikdarlingdata added a commit that referenced this pull request Sep 24, 2026
…ted (#4111)

Refs #4052.

Client-site PR #4090 narrowed the install-tree lock to grant the service
account Modify only on pg-runtime\ and pg-runtime-prev\, creating each
best-effort. A non-elevated shell cannot use the Administrators grant left
on the locked root, so the best-effort create fails and the lock reports
the directory in its open list instead of making it. Two tests read the
ACL of pg-runtime-prev\ unconditionally, so they threw or asserted the
wrong count outside CI's elevated run.

Both tests now branch on the elevated= marker they already print: elevated
assertions are unchanged, and the non-elevated branch asserts the lock's
own open: report instead of reading a path that was never created.


Claude-Session: https://claude.ai/code/session_01QQh8LczP1HGLXQYAx4oTZC

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Sep 24, 2026
* Add 48 buffered CHANGELOG entries in one splice

Adds 48 entries and 48 link refs (#4057, #4077, #4079, #4081, #4082, #4083, #4084, #4085, #4086, #4087, #4088, #4089, #4090, #4091, #4092, #4093, #4095, #4096, #4099, #4100, #4101, #4103, #4105, #4106, #4107, #4108, #4109, #4110, #4111, #4113, #4114, #4115, #4116, #4117, #4118, #4119, #4120, #4121, #4122, #4123, #4124, #4125, #4126, #4127, #4131, #4133, #4136, #4141). Each PR's entry was buffered, and this lands every entry whose PR was merged on origin/dev when it ran.

Entries found under a bare section line (none unless the old script ran again) move into the matching ### section, below the new entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQh8LczP1HGLXQYAx4oTZC

* Changelog splice: add #4137's entry, which merged before the splice but was missed

#4137 (pg_plan_capture reads csvlog) merged at 05:57Z with a CHANGELOG entry section in its
body. It was neither spliced nor listed as needing no entry. Its entry goes under Fixed, right
after #4136's, and #4136's last sentence now points to it instead of saying plan capture is
unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUU29PuGg9TUFBsACgZg2K

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant