fix(fs): make Windows admin ACE inheritable - #2760
Conversation
1e31952 to
4d014fb
Compare
5ed7ad8 to
295905a
Compare
aa90c83 to
d676f87
Compare
| remote_dir.as_path(), | ||
| local_dir.as_path(), | ||
| ]) | ||
| .map_err(|e| { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This has been changed to not bail out on failure. This now logs a report of the operation outcome and continues the startup.
gsanchezgavier
left a comment
There was a problem hiding this comment.
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.
| 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, |
There was a problem hiding this comment.
is this matching to what we set in the installation ps1?
There was a problem hiding this comment.
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).
| // 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([ |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
| /// 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) { |
There was a problem hiding this comment.
Nit: I'd try to place helpers together.
| ## 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. |
There was a problem hiding this comment.
Nit: I'd try to make the Changelog message shorter
| /// `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 { |
There was a problem hiding this comment.
Nit: do we need this to be public? (same for ensure_permissions_recursive)
| /// | ||
| /// 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. |
There was a problem hiding this comment.
Nit: Considering this, would it make sense to use io::Error::other directly?
4d83dcf to
191b473
Compare
Both done! |
1fe56fd to
78d2d4a
Compare
|
Fixed conflicts |
📦 Binary size
|
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. |
…ty) DACLs on startup (NR-601065)
…-> io::Error conversion
…ity, group test helpers
… one plain-language line
78d2d4a to
04de748
Compare
sigilioso
left a comment
There was a problem hiding this comment.
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!
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:
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)"] endRestarting 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 olderAdministrators:(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 ✅"]Repair decisions are logged at
debug(each root, each re-stamp) andtrace(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
modeterms 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 are0600) is "owner/admin-only, deny others," which this change preserves.Verification
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" — onefleet-dataconfig had a healthy-lookingAdministrators:(R,W)with no delete, written by a much older version and never rewritten.PAI(A;;0x13019f;;;BA)), collapsing the log /data/user_datato 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)).DELETEto that ACE (0x12019f→0x13019f, still non-inheritable/protected — the exact mask above) and (b) introduced the on-startup reconciliation + decommission cleanup, the deletion path that surfaces theos error 5on empty/no-delete DACLs. The protected + non-inheritable hardening itself predates both.repairing managed permissionson…\newrelic-infra, its integration/logging subdirs, and legacyfleet-data\agent-control\*) and leaves conforming ones (managed permissions intact, skipping). Empty-DACL count → 0; the per-agent dir is nowPAI(A;OICI;0x1301bf;;;BA)→ icacls(OI)(CI)(M); the infra sub-agent starts and reports healthy. ZeroAccess is denied/os error 5in the entire log.fleet-data\nr-infra\{remote_config,instance_id}.yamland thefilesystem\nr-infra+packages\nr-infratrees with noos error 5— the exact operation that failed pre-fix. Post-state: those paths gone, empty-count 0.(OI)(CI)(M)(children inheriting Modify) and the infra sub-agent returns andsub_agent_became_healthy.Tests
fs-crate unit tests on thewindows-latestleg ofunit-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 soremove_dir_allsucceeds (decommission).repair_reinstates_delete_on_read_write_only_file_from_older_version— theAdministrators:(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_permissionsis migrated from the rawwindows-sysbindings to the higher-levelwindowscrate (already a workspace dependency, used byagent-controlandself-replacer). Win32 calls now returnResult, 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 infs::win_permissions(the on-host runner just calls it and maps the error).fsno longer declareswindows-sysdirectly.Follow-up tracked in NR-601920: unify the repo's mixed
target_os/target_familycfg gating (deliberately out of scope here).