Skip to content

fix(fs): make Windows admin ACE inheritable - #2760

Merged
DavSanchez merged 17 commits into
mainfrom
fix-windows-subagent-log-acl
Aug 11, 2026
Merged

fix(fs): make Windows admin ACE inheritable#2760
DavSanchez merged 17 commits into
mainfrom
fix-windows-subagent-log-acl

Conversation

@DavSanchez

@DavSanchez DavSanchez commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Windows: files left inaccessible after an upgrade

What was broken

On Windows, upgrading Agent Control could leave files under its managed data root with permissions it couldn't use. Two symptoms seen in the field:

  • the infra sub-agent crash-looped — it couldn't manipulate its own log ("Access is denied"), despite running as the highest-privilege system account;
  • decommissioning a sub-agent failed — Agent Control couldn't delete the sub-agent's stored remote config ("Access is denied", os error 5).

Root cause

Every Windows file carries a DACL — the list of "who may touch this." Agent Control protects its folders so only Administrators (which includes the LocalSystem account it runs as) may access them, and a file created inside normally inherits that entry.

The bug: Agent Control stamped the folder's Administrators entry as non-inheritable. When Windows recomputes an inherited-only child's DACL and finds nothing to inherit, it leaves the child with an empty DACL — which denies everyone, including SYSTEM.

This only bites on upgrade, and exactly once:

flowchart TD
    subgraph FRESH["Fresh install — safe"]
        direction TB
        A1["folder protected FIRST<br/>(non-inheritable entry)"] --> A2["THEN sub-agent creates log"]
        A2 --> A3["log gets its OWN explicit entry<br/>✅ survives later re-protect"]
    end
    subgraph UPGRADE["Upgrade to a protecting version — breaks"]
        direction TB
        B1["old version: folder not protected<br/>log INHERITS access from parent"] --> B2["upgrade protects folder<br/>(non-inheritable, first time)"]
        B2 --> B3["Windows recomputes child:<br/>nothing to inherit → EMPTY DACL"]
        B3 --> B4["❌ nobody allowed, incl. SYSTEM<br/>(crash loop / can't delete)"]
    end
Loading

Restarting the same version changes nothing — a healthy file stays healthy, an emptied one stays empty. The damage happens only when a protecting version first meets files created before the folder was ever protected.

The fix — two parts

1. Make the entry inheritable. The folder's Administrators entry is now marked inheritable, so files created inside inherit access instead of ending up with an empty DACL. This prevents the bug on fresh installs and future upgrades.

2. Repair machines an upgrade already broke. Part 1 only affects newly-created files — it can't heal files an upgrade already emptied, and Agent Control never re-protects an existing folder. So on startup, Agent Control now walks its entire managed data root (sub-agent filesystem, stored remote configs under fleet-data, local data) and re-stamps any entry that doesn't already grant the access it needs — empty/unreadable, or missing a required right (e.g. an older Administrators:(R,W) with no delete, or a non-inheritable directory). Conforming entries are left untouched, so a healthy install isn't rewritten on every boot. Agent Control owns these files, so the rewrite succeeds even on an empty DACL, and grants read/write/execute and delete — fixing both the crash loop and the decommission failure. If any managed entry can't be repaired, Agent Control aborts startup rather than run on a data tree it can't fully access.

flowchart LR
    S["Agent Control start"] --> R["Recursive repair over whole data root:<br/>re-stamp entries lacking needed access;<br/>skip conforming ones"]
    R --> L["sub-agent log → openable ✅"]
    R --> F["stored remote configs → deletable ✅<br/>(decommission works)"]
    R --> D["data / local-data → accessible ✅"]
Loading

Repair decisions are logged at debug (each root, each re-stamp) and trace (per-entry reason: empty / NULL / unreadable / missing right), so an affected machine records exactly what it healed.

Security posture — unchanged

Files stay Administrators-only with a protected DACL; no new principal gains access (SYSTEM qualifies because it is an Administrator). The fix only restores the delete/execute/inheritance bits SYSTEM needs to manage files it already owns.

I checked Confluence for the rationale behind the original restriction: there is no DACI or design doc reasoning about the Windows ACL specifics. The explicit-filesystem spike that introduced this layout is written purely in POSIX mode terms and never addressed the Windows ACL model — which is the root of this bug. The documented intent everywhere it appears (the installer grants SYSTEM Full Control; Linux configs are 0600) is "owner/admin-only, deny others," which this change preserves.

Verification

  • Reproduced on a clean VM: 1.17.0 (works) → 1.18.0 (breaks); the resulting permissions matched a real affected machine byte-for-byte. Detail in NR-601065.
  • Field-confirmed: a Part-1-only binary healed the log but not the stored remote configs, so decommission still failed with os error 5. That is why repair is scoped to the whole data root, and why its predicate is "grants the required access" rather than merely "non-empty" — one fleet-data config had a healthy-looking Administrators:(R,W) with no delete, written by a much older version and never rewritten.
  • End-to-end validated on a clean Windows VM with the fixed build, across the version bisection and a full fleet lifecycle (ACL reports + logs attached to NR-601065):
    • 1.17.0 (before the explicit-filesystem work, feat: new explicit filesystem #2615 / Feat/filesystem persistence #2618): healthy, empty-DACL count 0 — baseline.
    • 1.18.0 (ships the explicit filesystem feat: new explicit filesystem #2615 and its persistence follow-up Feat/filesystem persistence #2618): breaks — the per-agent dir flips to protected + non-inheritable (PAI(A;;0x13019f;;;BA)), collapsing the log / data / user_data to empty DACLs (empty-count 3). The infra sub-agent crash-loops (Can't open log file … Access is denied) and ephemeral cleanup fails (deleting …\nr-infra\config: Access is denied (os error 5)).
      • Which PR surfaced what: feat: new explicit filesystem #2615 added the per-agent explicit-filesystem layout whose dirs get the protected, non-inheritable Administrators ACE — that's what surfaces the crash loop (inherited-only children collapse to empty DACLs). Feat/filesystem persistence #2618 then (a) added DELETE to that ACE (0x12019f0x13019f, still non-inheritable/protected — the exact mask above) and (b) introduced the on-startup reconciliation + decommission cleanup, the deletion path that surfaces the os error 5 on empty/no-delete DACLs. The protected + non-inheritable hardening itself predates both.
    • Fixed build: startup repair flags and re-stamps each insufficient entry (repairing managed permissions on …\newrelic-infra, its integration/logging subdirs, and legacy fleet-data\agent-control\*) and leaves conforming ones (managed permissions intact, skipping). Empty-DACL count → 0; the per-agent dir is now PAI(A;OICI;0x1301bf;;;BA) → icacls (OI)(CI)(M); the infra sub-agent starts and reports healthy. Zero Access is denied / os error 5 in the entire log.
    • Decommission (remote config removing the infra agent): AC deletes fleet-data\nr-infra\{remote_config,instance_id}.yaml and the filesystem\nr-infra + packages\nr-infra trees with no os error 5 — the exact operation that failed pre-fix. Post-state: those paths gone, empty-count 0.
    • Recommission (remote config re-adding it): the trees are recreated with (OI)(CI)(M) (children inheriting Modify) and the infra sub-agent returns and sub_agent_became_healthy.

Tests

fs-crate unit tests on the windows-latest leg of unit-docs-onhost-integration-tests, run as admin (required to set the ACLs they assert on):

  • child_created_in_managed_directory_inherits_admin_access — Part 1; fails on unfixed code, which the regression throwaway PR [DO NOT MERGE] Verify Windows ACL regression test fails on pre-fix code #2763 demonstrates.
  • recursive_repair_heals_existing_empty_dacl_child_from_older_version / ..._nested_empty_dacl_directory_and_file — repair restores access + openability on emptied files and directories.
  • repair_restores_delete_so_managed_tree_is_removable — repair restores DELETE so remove_dir_all succeeds (decommission).
  • repair_reinstates_delete_on_read_write_only_file_from_older_version — the Administrators:(R,W)-with-no-delete field case.
  • permissions_need_repair_flags_only_broken_entries — detection gate leaves conforming entries alone, so healthy trees aren't re-stamped.
  • repair_makes_a_non_inheritable_managed_directory_inheritable — a non-inheritable managed directory is flagged and re-stamped inheritable, so future runtime children inherit access.

Also in this change (internal, no behavior change)

win_permissions is migrated from the raw windows-sys bindings to the higher-level windows crate (already a workspace dependency, used by agent-control and self-replacer). Win32 calls now return Result, so error handling is ? / .ok()? instead of manual == 0 + GetLastError, and FFI failures are logged with the OS error message. The DACL check is now a pure predicate (grants_managed_admin_access) driven by .any() rather than an imperative scan loop, and the whole ACL story — stamping, the check, the recursive repair, and the multi-root startup entry point (ensure_managed_permissions) — lives in fs::win_permissions (the on-host runner just calls it and maps the error). fs no longer declares windows-sys directly.

Follow-up tracked in NR-601920: unify the repo's mixed target_os/target_family cfg gating (deliberately out of scope here).

@DavSanchez
DavSanchez force-pushed the fix-windows-subagent-log-acl branch 2 times, most recently from 1e31952 to 4d014fb Compare July 30, 2026 21:41
@DavSanchez
DavSanchez marked this pull request as ready for review July 30, 2026 22:24
@DavSanchez
DavSanchez requested a review from a team as a code owner July 30, 2026 22:24
Comment thread fs/src/win_permissions.rs Fixed
@DavSanchez
DavSanchez force-pushed the fix-windows-subagent-log-acl branch 3 times, most recently from 5ed7ad8 to 295905a Compare July 31, 2026 17:10
@DavSanchez
DavSanchez marked this pull request as draft August 3, 2026 10:48
@DavSanchez DavSanchez changed the title fix(fs): make Windows admin ACE inheritable so sub-agents can write logs fix(fs): make Windows admin ACE inheritable Aug 3, 2026
@DavSanchez
DavSanchez force-pushed the fix-windows-subagent-log-acl branch 8 times, most recently from aa90c83 to d676f87 Compare August 4, 2026 17:40
@DavSanchez
DavSanchez marked this pull request as ready for review August 5, 2026 11:34
remote_dir.as_path(),
local_dir.as_path(),
])
.map_err(|e| {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not exit in case of error with permissions, there is the risk of causing a deadlock of the application in which we are not able to update because of this, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point! I initially erred in the side of caution (just quit if we cannot ensure proper operation, but it's true that the self-upgrade can work as a healing path. I'm updating it to not stop AC but log any failures as warnings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been changed to not bail out on failure. This now logs a report of the operation outcome and continues the startup.

@gsanchezgavier gsanchezgavier left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me , i just left a question. On the other hand i think we took a complex path by setting this permissions in AC and we should leverage the permissions inheritance of root folders that are set at installation time, and we are hitting that complexity with things like this bug.

Comment thread fs/src/win_permissions.rs
Comment on lines 96 to 104
let access_entry = EXPLICIT_ACCESS_W {
grfAccessPermissions: GENERIC_READ | GENERIC_WRITE | DELETE,
grfAccessPermissions: (FILE_GENERIC_READ
| FILE_GENERIC_WRITE
| FILE_GENERIC_EXECUTE
| DELETE)
.0,
grfAccessMode: SET_ACCESS,
grfInheritance: NO_INHERITANCE,
grfInheritance: OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
Trustee: trustee,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this matching to what we set in the installation ps1?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matches to a sub-set of it. The PowerShell script sets Full Control (F), here we use a least privilege approach and use only Modify (M).

Comment on lines +97 to +101
// Windows-only: check and repair the managed data tree before anything reads/writes/deletes
// it (see NR-601065). Fails early — we cannot ensure proper AC operation if permissions
// aren't right.
#[cfg(target_family = "windows")]
fs::win_permissions::ensure_managed_permissions([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think it would be ideal if we have a FF to control this kind of behaviour. We could activated just for exiting customers for instance. Out of scope of this fix for sure

@sigilioso sigilioso left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left some nits that can be perfectly ignored. On top of that, I'd take a look at the logs/Errors style conventions (there are some lowercase/uppercase inconsistencies).

The fix looks good to me but I'd address @paologallinaharbur comment regarding existing on error before merging.

Comment thread fs/src/win_permissions.rs Outdated
/// Administrators-only ACE granting only read+write (mask `0x12019f`) with **no DELETE** and no
/// execute. This is the state that leaves a stored remote config undeletable on decommission
/// (NR-601065). Non-inheritable, matching what was observed in the field.
fn legacy_harden_read_write_only(path: &Path) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd try to place helpers together.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread CHANGELOG.md Outdated
## Unreleased

### bugfix
- Windows: fixed files under Agent Control's managed directories being left with permissions it could not use. This could block a range of operations: a sub-agent could not modify its own assets (e.g. log rotation for the infrastructure-agent), and Agent Control could not delete a sub-agent's stored remote config or data when decommissioning it ("Access is denied"). Managed directories now use an inheritable Administrators ACE so runtime-created files inherit access, and Agent Control now preemptively checks and repairs its managed directories on startup by re-stamping any managed entry that does not already grant the required access.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd try to make the Changelog message shorter

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread fs/src/win_permissions.rs Outdated
/// `set_file_permissions_for_administrator` can never itself leave behind a stray `Deny` ACE for this
/// function to misread. Noted here as a defense-in-depth gap in case a `Deny` ACE is ever introduced
/// by something other than this code path (e.g. third-party security tooling).
pub fn permissions_need_repair(path: &Path) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: do we need this to be public? (same for ensure_permissions_recursive)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread fs/src/win_permissions.rs Outdated
Comment on lines +24 to +28
///
/// Internal to this crate: nothing outside `fs` calls [`set_file_permissions_for_administrator`]
/// directly, so this never needs to cross the crate boundary. Callers within `fs` convert it to
/// [`io::Error`] — either via the [`From`] impl below when no extra context is needed, or with a
/// `.map_err` that adds the path being operated on.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Considering this, would it make sense to use io::Error::other directly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@DavSanchez
DavSanchez force-pushed the fix-windows-subagent-log-acl branch from 4d83dcf to 191b473 Compare August 7, 2026 13:19
@DavSanchez

Copy link
Copy Markdown
Contributor Author

[...] I'd take a look at the logs/Errors style conventions (there are some lowercase/uppercase inconsistencies).

[...] I'd address @paologallinaharbur comment regarding existing on error before merging.

Both done!

@DavSanchez
DavSanchez force-pushed the fix-windows-subagent-log-acl branch from 1fe56fd to 78d2d4a Compare August 10, 2026 09:30
@DavSanchez

Copy link
Copy Markdown
Contributor Author

Fixed conflicts

@github-actions

Copy link
Copy Markdown
Contributor

📦 Binary size

Binary main PR Δ
newrelic-agent-control 21.92 MB 21.93 MB ⬆️ +0.00 MB (0.01%)
newrelic-agent-control-cli 13.66 MB 13.66 MB ⬇️ -0.00 MB (-0.00%)

@DavSanchez

DavSanchez commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

📦 Binary size

Binary main PR Δ
newrelic-agent-control 21.92 MB 21.93 MB ⬆️ +0.00 MB (0.01%)
newrelic-agent-control-cli 13.66 MB 13.66 MB ⬇️ -0.00 MB (-0.00%)

Now that I see this @vjripoll maybe it could be useful to have more rows to specify per OS/arch, because I added code that is Windows-only it won't be reflected here.

@sigilioso sigilioso added the onhost-extended-e2e Execution of on host e2e in the current branch label Aug 10, 2026
@DavSanchez
DavSanchez force-pushed the fix-windows-subagent-log-acl branch from 78d2d4a to 04de748 Compare August 10, 2026 14:11
sigilioso
sigilioso previously approved these changes Aug 10, 2026

@sigilioso sigilioso left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think logs should begin with a capital letter according to the style guide.

I've left a comment as example, but I'd take a look at all logs.

Everything else looks good!

Comment thread fs/src/win_permissions.rs
@DavSanchez
DavSanchez merged commit 22bcda9 into main Aug 11, 2026
56 checks passed
@DavSanchez
DavSanchez deleted the fix-windows-subagent-log-acl branch August 11, 2026 06:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

onhost-extended-e2e Execution of on host e2e in the current branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants