From 70be59bcac12bb5c6bcf5a607c33c9dff95da338 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 17 Oct 2026 09:43:30 -0700 Subject: [PATCH 1/9] Add shared AccessControlList library Introduce typed DACL and SACL management for filesystem and registry resources, including SID or username principals, resource-specific permission enums, and merge behavior through _purge. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 12 + Cargo.toml | 7 + data.build.json | 6 + .../Microsoft/Windows/Registry/index.md | 226 +++++- lib/dsc-lib-registry/Cargo.toml | 1 + lib/dsc-lib-registry/locales/en-us.toml | 3 +- lib/dsc-lib-registry/src/acl.rs | 442 ++++++------ lib/dsc-lib-registry/src/config.rs | 101 ++- lib/dsc-lib-registry/src/lib.rs | 37 +- lib/lib-acl/.project.data.json | 5 + lib/lib-acl/Cargo.toml | 18 + lib/lib-acl/src/error.rs | 41 ++ lib/lib-acl/src/lib.rs | 21 + lib/lib-acl/src/model.rs | 677 ++++++++++++++++++ lib/lib-acl/src/win32/acl.rs | 244 +++++++ lib/lib-acl/src/win32/mod.rs | 17 + lib/lib-acl/src/win32/privilege.rs | 83 +++ lib/lib-acl/src/win32/security_descriptor.rs | 309 ++++++++ lib/lib-acl/src/win32/sid.rs | 139 ++++ resources/file_dsc/Cargo.toml | 1 + .../file_dsc/file_dsc.dsc.manifests.json | 146 +++- resources/file_dsc/locales/en-us.toml | 12 +- resources/file_dsc/src/acl.rs | 269 +++---- resources/file_dsc/src/main.rs | 4 +- resources/file_dsc/src/types.rs | 120 +++- .../file_dsc/tests/filesystem_acl.tests.ps1 | 303 +++++++- .../tests/registry.config.get.tests.ps1 | 15 +- .../tests/registry.config.set.tests.ps1 | 209 +++++- .../tests/registry.config.whatif.tests.ps1 | 36 +- .../registry/tests/registrylist_get.tests.ps1 | 9 +- .../registry/tests/registrylist_set.tests.ps1 | 36 +- .../tests/registrylist_whatif.tests.ps1 | 40 +- 32 files changed, 3052 insertions(+), 537 deletions(-) create mode 100644 lib/lib-acl/.project.data.json create mode 100644 lib/lib-acl/Cargo.toml create mode 100644 lib/lib-acl/src/error.rs create mode 100644 lib/lib-acl/src/lib.rs create mode 100644 lib/lib-acl/src/model.rs create mode 100644 lib/lib-acl/src/win32/acl.rs create mode 100644 lib/lib-acl/src/win32/mod.rs create mode 100644 lib/lib-acl/src/win32/privilege.rs create mode 100644 lib/lib-acl/src/win32/security_descriptor.rs create mode 100644 lib/lib-acl/src/win32/sid.rs diff --git a/Cargo.lock b/Cargo.lock index 31ec37535..18907e07f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -946,6 +946,7 @@ version = "1.3.0" dependencies = [ "cc", "crossterm", + "lib-acl", "registry", "rust-i18n", "schemars", @@ -1190,6 +1191,7 @@ name = "file_dsc" version = "0.1.0" dependencies = [ "base16ct", + "lib-acl", "rust-i18n", "serde", "serde_json", @@ -1917,6 +1919,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lib-acl" +version = "0.1.0" +dependencies = [ + "schemars", + "serde", + "thiserror 2.0.21", + "windows 0.62.2", +] + [[package]] name = "libc" version = "0.2.189" diff --git a/Cargo.toml b/Cargo.toml index d371a5b4d..5b02a6d03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "lib/dsc-lib-security_context", "lib/dsc-lib-telemetry", "lib/dsc-lib", + "lib/lib-acl", "resources/dism_dsc", "resources/dscecho", "resources/file_dsc", @@ -50,6 +51,7 @@ default-members = [ "lib/dsc-lib-security_context", "lib/dsc-lib-telemetry", "lib/dsc-lib", + "lib/lib-acl", "resources/dism_dsc", "resources/dscecho", "resources/file_dsc", @@ -86,6 +88,7 @@ Windows = [ "lib/dsc-lib-security_context", "lib/dsc-lib-telemetry", "lib/dsc-lib", + "lib/lib-acl", "resources/dism_dsc", "resources/dscecho", "resources/file_dsc", @@ -115,6 +118,7 @@ macOS = [ "lib/dsc-lib-security_context", "lib/dsc-lib-telemetry", "lib/dsc-lib", + "lib/lib-acl", "resources/dscecho", "resources/file_dsc", "resources/osinfo", @@ -137,6 +141,7 @@ Linux = [ "lib/dsc-lib-security_context", "lib/dsc-lib-telemetry", "lib/dsc-lib", + "lib/lib-acl", "resources/dscecho", "resources/file_dsc", "resources/osinfo", @@ -301,6 +306,7 @@ windows = { version = "0.62", features = [ "Win32_System_Ole", "Win32_System_Registry", "Win32_System_Services", + "Win32_System_Threading", "Win32_System_Variant", "Win32_System_UpdateAgent", "Win32_Globalization" @@ -326,5 +332,6 @@ dsc-lib-osinfo = { path = "lib/dsc-lib-osinfo" } dsc-lib-registry = { path = "lib/dsc-lib-registry" } dsc-lib-security_context = { path = "lib/dsc-lib-security_context" } dsc-lib-telemetry = { path = "lib/dsc-lib-telemetry" } +lib-acl = { path = "lib/lib-acl" } tree-sitter-dscexpression = { path = "grammars/tree-sitter-dscexpression" } tree-sitter-ssh-server-config = { path = "grammars/tree-sitter-ssh-server-config" } diff --git a/data.build.json b/data.build.json index a91dc0240..8b98050bf 100644 --- a/data.build.json +++ b/data.build.json @@ -285,6 +285,12 @@ "RelativePath": "lib/dsc-lib-osinfo", "IsRust": true }, + { + "Name": "lib-acl", + "Kind": "Library", + "RelativePath": "lib/lib-acl", + "IsRust": true + }, { "Name": "dsc-lib-pal", "Kind": "Library", diff --git a/docs/reference/resources/Microsoft/Windows/Registry/index.md b/docs/reference/resources/Microsoft/Windows/Registry/index.md index 5343ccd81..ec02fc88d 100644 --- a/docs/reference/resources/Microsoft/Windows/Registry/index.md +++ b/docs/reference/resources/Microsoft/Windows/Registry/index.md @@ -1,6 +1,6 @@ --- description: Microsoft.Windows/Registry resource reference documentation -ms.date: 10/13/2026 +ms.date: 10/17/2026 ms.topic: reference title: Microsoft.Windows/Registry --- @@ -35,7 +35,7 @@ resources: keyPath: string # Instance properties _exist: - acl: + accessControlList: valueData: valueName: ``` @@ -108,7 +108,8 @@ The following list describes the properties for the resource. They define the desired state for an instance of the resource. - [_exist](#_exist) - Defines whether the registry key or value should exist. - - [acl](#acl) - Defines the registry key ACL in Security Descriptor Definition Language (SDDL). + - [accessControlList](#accesscontrollist) - Defines the registry key's discretionary and + system access control lists. - [valueData](#valuedata) - The data for a registry value. - [valueName](#valuename) - The name of the registry value. @@ -165,12 +166,12 @@ value for `_exist` is `true`, the resource adds or creates the instance if it do the value for `_exist` is `false`, the resource removes or deletes the instance if it does exist. The default value for this property when not specified for an instance is `true`. -### acl +### accessControlList -
Expand for acl property metadata +
Expand for accessControlList property metadata ```yaml -Type : string +Type : object IsRequired : false IsKey : false IsReadOnly : false @@ -179,13 +180,149 @@ IsWriteOnly : false
-Defines the registry key ACL in Security Descriptor Definition Language (SDDL). For **Get** -operations, include the owner (`O:`), group (`G:`), or discretionary access control list (`D:`) -section in the input to retrieve those sections. For **Set** operations, the resource applies only -the sections specified in the SDDL string. System access control lists (`S:`) aren't supported. +Defines the registry key's access control list (ACL) as typed arrays of access control entry (ACE) +objects, instead of a raw Security Descriptor Definition Language (SDDL) string. The ACL has two +independent sections, the discretionary access control list (DACL) and the system access control +list (SACL). Both sections are optional. Omit a section to leave it unmanaged; the resource doesn't +read, return, or change a section that isn't specified in the input. For **Get** operations, +include a section (even as an empty array) to request that the resource return its current +entries. + +Reading or writing the `system` section requires the process invoking the resource to hold the +`SeSecurityPrivilege` privilege. The resource enables this privilege automatically when needed and +returns a clear error if it can't be enabled. + +Each entry's `principal` property accepts either a security identifier (SID) string, like +`S-1-5-32-544`, or an account or group name, like `DOMAIN\User` or `Administrators`. The resource +resolves names to a SID using the Windows security APIs; the value the resource returns for +`principal` is always the normalized SID string, even when the input specified a name. + +This property applies to the key identified by `keyPath`, including when the instance also +specifies a registry value. `accessControlList` operations aren't supported when using an offline +registry hive. The resource doesn't manage the key's owner or primary group. + +`accessControlList` has the following properties: + +- [discretionary](#discretionary-accesscontrollist) - The discretionary access control list (DACL) + entries. +- [discretionaryProtected](#discretionaryprotected-accesscontrollist) - Whether the DACL is + protected from inheriting entries from its parent. +- [system](#system-accesscontrollist) - The system access control list (SACL) entries. +- [systemProtected](#systemprotected-accesscontrollist) - Whether the SACL is protected from + inheriting entries from its parent. +- [_purge](#_purge-accesscontrollist) - Whether to replace or merge the supplied section(s). + Defaults to `true`. + +#### discretionary accessControlList + +
Expand for accessControlList.discretionary subproperty metadata + +```yaml +Type : array +ItemsType : object +ItemsMustBeUnique : false +ItemsMinimumCount : 0 +``` + +
+ +Defines the discretionary access control list (DACL) entries for the key. Omit this property to +leave the DACL unmanaged. Specify an empty array to request the current entries without changing +them, or during a **Set** operation, to clear every explicit entry from the DACL. Each item is an +[access control entry object](#access-control-entry-object). + +#### discretionaryProtected accessControlList + +
Expand for accessControlList.discretionaryProtected subproperty metadata + +```yaml +Type : boolean +``` + +
+ +Defines whether the DACL is protected from inheriting entries from its parent key. Omit this +property to leave the current protection state unchanged when `discretionary` isn't specified, or +to request an unprotected (inheriting) DACL when it is specified without this property. + +#### system accessControlList + +
Expand for accessControlList.system subproperty metadata + +```yaml +Type : array +ItemsType : object +ItemsMustBeUnique : false +ItemsMinimumCount : 0 +``` + +
+ +Defines the system access control list (SACL) entries for the key. Omit this property to leave the +SACL unmanaged. Specify an empty array to request the current entries without changing them, or +during a **Set** operation, to clear every explicit entry from the SACL. Every entry must have a +`type` of `audit`. Each item is an [access control entry object](#access-control-entry-object). + +#### systemProtected accessControlList + +
Expand for accessControlList.systemProtected subproperty metadata -This property applies to the key identified by `keyPath`, including when the instance also specifies -a registry value. ACL operations aren't supported when using an offline registry hive. +```yaml +Type : boolean +``` + +
+ +Defines whether the SACL is protected from inheriting entries from its parent key. Omit this +property to leave the current protection state unchanged when `system` isn't specified, or to +request an unprotected (inheriting) SACL when it is specified without this property. + +#### _purge accessControlList + +
Expand for accessControlList._purge subproperty metadata + +```yaml +Type : boolean +Default : true +``` + +
+ +Defines whether to replace (purge) each supplied section outright, or merge its entries into the +section's current managed entries. Defaults to `true` (replace) when omitted, which is the +historical full-replacement behavior: the `discretionary` and/or `system` entries supplied in a +**Set** operation become the section's entire content. + +When `false`, the entries supplied for a section are merged into that section's existing entries +instead of replacing them: entries that already exist are left in place, newly supplied entries are +appended, and semantic duplicates (the same normalized principal, ACE type, effective access mask, +and inheritance/audit flags) aren't duplicated. After merging, the resulting `discretionary` section +is canonicalized so that every `deny` entry precedes every `allow` entry. A section that isn't +supplied in the request remains unmanaged regardless of this setting. + +For a **Test** operation, `_purge: false` is satisfied when every requested entry is present +(after normalization) in the actual access control list; extra entries already present in the +actual ACL don't cause a mismatch. `_purge: true` (the default) requires an exact match. + +#### Access control entry object + +Each item in the `discretionary` and `system` arrays is an object with the following properties: + +- **principal** (required) - A SID string or an account or group name. The resource always + returns the normalized SID string for this property. +- **type** (required) - Whether the entry allows, denies, or audits access. Must be `allow` or + `deny` for entries in `discretionary`, and `audit` for entries in `system`. Valid values are + `allow`, `deny`, and `audit`. +- **accessMask** (required) - The permissions granted, denied, or audited by this entry. Must + specify at least one value. Valid values are `queryValue`, `setValue`, `createSubKey`, + `enumerateSubKeys`, `notify`, `createLink`, `wow6464Key`, `wow6432Key`, `delete`, + `readPermissions`, `changePermissions`, `takeOwnership`, `synchronize`, `read`, `write`, + `execute`, and `fullControl`. +- **flags** (optional) - Inheritance flags for the entry. Defaults to an empty array (no + inheritance). Valid values are `containerInherit`, `objectInherit`, `noPropagateInherit`, + `inheritOnly`, and `inherited`. +- **auditFlags** (optional) - The audit conditions for the entry. Required (and non-empty) when + `type` is `audit`; must be empty otherwise. Valid values are `success` and `failure`. ### valueData @@ -403,8 +540,69 @@ non validating keywords are omitted. } } }, - "acl": { - "type": "string" + "accessControlList": { + "type": "object", + "additionalProperties": false, + "properties": { + "discretionary": { + "type": "array", + "items": { + "type": "object", + "required": ["principal", "type", "accessMask"], + "additionalProperties": false, + "properties": { + "principal": { + "type": "string" + }, + "type": { + "enum": ["allow", "deny", "audit"] + }, + "accessMask": { + "type": "array", + "items": { + "enum": [ + "queryValue", "setValue", "createSubKey", "enumerateSubKeys", "notify", + "createLink", "wow6464Key", "wow6432Key", "delete", "readPermissions", + "changePermissions", "takeOwnership", "synchronize", "read", "write", + "execute", "fullControl" + ] + } + }, + "flags": { + "type": "array", + "items": { + "enum": [ + "containerInherit", "objectInherit", "noPropagateInherit", "inheritOnly", + "inherited" + ] + } + }, + "auditFlags": { + "type": "array", + "items": { + "enum": ["success", "failure"] + } + } + } + } + }, + "discretionaryProtected": { + "type": "boolean" + }, + "system": { + "type": "array", + "items": { + "$ref": "#/properties/accessControlList/properties/discretionary/items" + } + }, + "systemProtected": { + "type": "boolean" + }, + "_purge": { + "type": "boolean", + "default": true + } + } }, "keyPath": { "type": "string", diff --git a/lib/dsc-lib-registry/Cargo.toml b/lib/dsc-lib-registry/Cargo.toml index 2d21387d7..96b90f9a5 100644 --- a/lib/dsc-lib-registry/Cargo.toml +++ b/lib/dsc-lib-registry/Cargo.toml @@ -13,6 +13,7 @@ doctest = false [dependencies] crossterm = { workspace = true } +lib-acl = { workspace = true } registry = { workspace = true } rust-i18n = { workspace = true } schemars = { workspace = true } diff --git a/lib/dsc-lib-registry/locales/en-us.toml b/lib/dsc-lib-registry/locales/en-us.toml index 55baa305f..db30118b7 100644 --- a/lib/dsc-lib-registry/locales/en-us.toml +++ b/lib/dsc-lib-registry/locales/en-us.toml @@ -5,7 +5,7 @@ _version = 1 [error] acl = "Registry ACL" -invalidAcl = "The SDDL must define an owner, group, or DACL" +invalidAcl = "At least one of accessControlList.discretionary or accessControlList.system must be specified" invalidHive = "Invalid hive" json = "JSON" offlineRegistry = "Offline registry" @@ -15,7 +15,6 @@ registry = "Registry" registryKey = "Registry key" registryKeyNotFound = "Registry key not found" registryValue = "Registry value" -saclUnsupported = "System ACLs (SACLs) are not supported" utf16Conversion = "UTF-16 conversion failed due to interior NULL values for" unsupportedValueDataType = "Unsupported registry value data type" win32 = "%{operation} failed with error code %{code}" diff --git a/lib/dsc-lib-registry/src/acl.rs b/lib/dsc-lib-registry/src/acl.rs index 197d9e49f..77a9e8cf0 100644 --- a/lib/dsc-lib-registry/src/acl.rs +++ b/lib/dsc-lib-registry/src/acl.rs @@ -3,33 +3,36 @@ use core::ffi::c_void; -use windows::Win32::Foundation::{ - ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, ERROR_PATH_NOT_FOUND, ERROR_SUCCESS, HLOCAL, - LocalFree, -}; -use windows::Win32::Security::Authorization::{ - ConvertSecurityDescriptorToStringSecurityDescriptorW, - ConvertStringSecurityDescriptorToSecurityDescriptorW, +use lib_acl::AccessControlList; +use lib_acl::win32::{ + build_self_relative_security_descriptor, enable_security_privilege, normalize, + read_access_control_list, read_security_information, }; -use windows::Win32::Security::{ - ACL, DACL_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, GetSecurityDescriptorControl, - GetSecurityDescriptorDacl, GetSecurityDescriptorGroup, GetSecurityDescriptorOwner, - GetSecurityDescriptorSacl, OBJECT_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, - PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED, - UNPROTECTED_DACL_SECURITY_INFORMATION, +use lib_acl::merge_access_control_list; +use windows::Win32::Foundation::{ + ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, ERROR_PATH_NOT_FOUND, ERROR_SUCCESS, }; +use windows::Win32::Security::PSECURITY_DESCRIPTOR; use windows::Win32::System::Registry::{ HKEY, HKEY_CLASSES_ROOT, HKEY_CURRENT_CONFIG, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, - HKEY_USERS, REG_SAM_FLAGS, RegCloseKey, RegGetKeySecurity, RegOpenKeyExW, - RegSetKeySecurity, + HKEY_USERS, REG_SAM_FLAGS, RegCloseKey, RegGetKeySecurity, RegOpenKeyExW, RegSetKeySecurity, }; -use windows::core::{BOOL, HSTRING, PCWSTR, PWSTR}; +use windows::core::{HSTRING, PCWSTR}; +use crate::config::RegistryAccessRight; use crate::error::RegistryError; use rust_i18n::t; -const SDDL_REVISION_1: u32 = 1; -const READ_CONTROL_ACCESS: REG_SAM_FLAGS = REG_SAM_FLAGS(0x0002_0000); +/// `READ_CONTROL`: required to read a DACL (but not a SACL) from a security descriptor. +const READ_CONTROL: REG_SAM_FLAGS = REG_SAM_FLAGS(0x0002_0000); +/// `WRITE_DAC`: required to write a DACL to a security descriptor. +const WRITE_DAC: REG_SAM_FLAGS = REG_SAM_FLAGS(0x0004_0000); +/// `ACCESS_SYSTEM_SECURITY`: required, in combination with the `SeSecurityPrivilege` privilege, +/// to read or write a SACL. Not exposed as a named constant by the `windows` crate for registry +/// access masks, so it is reproduced here from its well-known, stable value. +const ACCESS_SYSTEM_SECURITY: REG_SAM_FLAGS = REG_SAM_FLAGS(0x0100_0000); + +type RegistryAccessControlList = AccessControlList; struct RegistryKey(HKEY); @@ -42,78 +45,99 @@ impl Drop for RegistryKey { } } -struct LocalAllocation(HLOCAL); +/// Validates that `requested` is well-formed (for example that `audit` entries are only used in +/// the system section) without touching the registry. +/// +/// # Errors +/// +/// Returns [`RegistryError::Acl`] when `requested` is not well-formed. +pub fn validate(requested: &RegistryAccessControlList) -> Result<(), RegistryError> { + requested.validate().map_err(acl_error) +} -impl Drop for LocalAllocation { - fn drop(&mut self) { - // SAFETY: SDDL conversion APIs allocate this pointer with LocalAlloc. - unsafe { - let _ = LocalFree(Some(self.0)); - } +/// Reads the sections of `path`'s access control list that are requested (non-`None`) in +/// `requested`. Reading the system access control list (SACL) requires the current process to +/// hold the `SeSecurityPrivilege` privilege, which this function enables as needed. +/// +/// # Errors +/// +/// Returns [`RegistryError::RegistryKeyNotFound`] when `path` does not exist, or +/// [`RegistryError::Acl`] when a lower-level Win32 API call fails. +pub fn get( + path: &str, + requested: &RegistryAccessControlList, +) -> Result { + let want_discretionary = requested.discretionary.is_some(); + let want_system = requested.system.is_some(); + if !want_discretionary && !want_system { + return Err(RegistryError::Acl(t!("error.invalidAcl").to_string())); } -} -pub fn get(path: &str, requested_sddl: &str) -> Result { - let descriptor = SecurityDescriptor::from_sddl(requested_sddl)?; - let (security_information, _) = descriptor.permissions(false)?; - let key = open(path, READ_CONTROL_ACCESS)?; - let mut size = 0; + if want_system { + enable_security_privilege().map_err(acl_error)?; + } + let key = open(path, read_access(requested))?; + let security_information = read_security_information(want_discretionary, want_system); + + let mut size = 0; // SAFETY: The first call intentionally supplies no buffer to obtain the required size. let status = unsafe { RegGetKeySecurity(key.0, security_information, None, &mut size) }; if status != ERROR_INSUFFICIENT_BUFFER { return Err(win32_error("RegGetKeySecurity", status.0)); } - let mut descriptor = vec![0_u8; size as usize]; - let security_descriptor = PSECURITY_DESCRIPTOR(descriptor.as_mut_ptr().cast::()); - // SAFETY: The descriptor buffer is writable and has the size requested by RegGetKeySecurity. - let status = unsafe { - RegGetKeySecurity( - key.0, - security_information, - Some(security_descriptor), - &mut size, - ) - }; + let mut descriptor_bytes = vec![0_u8; size as usize]; + let descriptor = PSECURITY_DESCRIPTOR(descriptor_bytes.as_mut_ptr().cast::()); + // SAFETY: descriptor_bytes is writable and has the size requested by RegGetKeySecurity above. + let status = + unsafe { RegGetKeySecurity(key.0, security_information, Some(descriptor), &mut size) }; if status != ERROR_SUCCESS { return Err(win32_error("RegGetKeySecurity", status.0)); } - let mut sddl = PWSTR::null(); - // SAFETY: The security descriptor was populated by RegGetKeySecurity. The returned string is - // released by LocalAllocation. - unsafe { - ConvertSecurityDescriptorToStringSecurityDescriptorW( - security_descriptor, - SDDL_REVISION_1, - security_information, - &mut sddl, - None, - ) - .map_err(|error| RegistryError::Acl(error.to_string()))?; - } - let allocation = LocalAllocation(HLOCAL(sddl.0.cast::())); - // SAFETY: The conversion API returned a null-terminated UTF-16 string. - let result = unsafe { sddl.to_string() }.map_err(|error| RegistryError::Acl(error.to_string())); - drop(allocation); - result + read_access_control_list(&mut descriptor_bytes, want_discretionary, want_system) + .map_err(acl_error) } -pub fn validate(sddl: &str) -> Result<(), RegistryError> { - SecurityDescriptor::from_sddl(sddl)?.permissions(false)?; - Ok(()) +/// Returns whether `path` currently exists and could accept `requested` being applied via +/// [`set`]. +/// +/// # Errors +/// +/// Returns [`RegistryError::Acl`] when a lower-level Win32 API call fails for a reason other +/// than the key not existing. +pub fn can_set(path: &str, requested: &RegistryAccessControlList) -> Result { + match open(path, write_access(requested)) { + Ok(_) => Ok(true), + Err(RegistryError::RegistryKeyNotFound(_)) => Ok(false), + Err(error) => Err(error), + } } -pub fn set(path: &str, sddl: &str) -> Result<(), RegistryError> { - let descriptor = SecurityDescriptor::from_sddl(sddl)?; - let (security_information, access) = descriptor.permissions(true)?; - let key = open(path, access)?; +/// Applies the sections of `requested` that are populated (non-`None`) to `path`'s access +/// control list. Writing the system access control list (SACL) requires the current process to +/// hold the `SeSecurityPrivilege` privilege, which this function enables as needed. +/// +/// # Errors +/// +/// Returns [`RegistryError::RegistryKeyNotFound`] when `path` does not exist, or +/// [`RegistryError::Acl`] when `requested` is not well-formed or a lower-level Win32 API call +/// fails. +pub fn set(path: &str, requested: &RegistryAccessControlList) -> Result<(), RegistryError> { + requested.validate().map_err(acl_error)?; + if requested.system.is_some() { + enable_security_privilege().map_err(acl_error)?; + } - // SAFETY: The descriptor was returned by the SDDL conversion API and remains allocated for the - // duration of this call. - let status = - unsafe { RegSetKeySecurity(key.0, security_information, descriptor.descriptor) }; + let to_write = effective(path, requested)?; + let (mut descriptor_bytes, security_information) = + build_self_relative_security_descriptor(&to_write).map_err(acl_error)?; + let descriptor = PSECURITY_DESCRIPTOR(descriptor_bytes.as_mut_ptr().cast::()); + + let key = open(path, write_access(requested))?; + // SAFETY: descriptor_bytes remains alive for the duration of this call. + let status = unsafe { RegSetKeySecurity(key.0, security_information, descriptor) }; if status != ERROR_SUCCESS { return Err(win32_error("RegSetKeySecurity", status.0)); } @@ -121,124 +145,58 @@ pub fn set(path: &str, sddl: &str) -> Result<(), RegistryError> { Ok(()) } -pub fn can_set(path: &str, sddl: &str) -> Result { - let descriptor = SecurityDescriptor::from_sddl(sddl)?; - let (_, access) = descriptor.permissions(true)?; - match open(path, access) { - Ok(_) => Ok(true), - Err(RegistryError::RegistryKeyNotFound(_)) => Ok(false), - Err(error) => Err(error), +/// Computes the effective access control list that should be written for `requested`, honoring +/// its `_purge` setting: when `true` (the default), returns `requested` (normalized) as-is, so +/// that [`set`] continues to replace each supplied section outright exactly as before. When +/// `false`, reads `path`'s current state for the section(s) `requested` manages and merges +/// `requested`'s entries into them (deduplicating semantic duplicates and ordering `deny` +/// entries first in the discretionary section). +/// +/// # Errors +/// +/// Returns [`RegistryError::RegistryKeyNotFound`] when `path` does not exist, or +/// [`RegistryError::Acl`] when a principal cannot be resolved or a lower-level Win32 API call +/// fails. +pub fn effective( + path: &str, + requested: &RegistryAccessControlList, +) -> Result { + let normalized = normalize(requested).map_err(acl_error)?; + if normalized.purge() { + return Ok(normalized); } -} -struct SecurityDescriptor { - descriptor: PSECURITY_DESCRIPTOR, - _allocation: LocalAllocation, + let current = get(path, &normalized)?; + Ok(merge_access_control_list(¤t, &normalized)) } -impl SecurityDescriptor { - fn from_sddl(sddl: &str) -> Result { - let sddl = HSTRING::from(sddl); - let mut descriptor = PSECURITY_DESCRIPTOR::default(); - - // SAFETY: The input is a valid null-terminated HSTRING. The returned descriptor is owned by - // the LocalAllocation field. - unsafe { - ConvertStringSecurityDescriptorToSecurityDescriptorW( - PCWSTR(sddl.as_ptr()), - SDDL_REVISION_1, - &mut descriptor, - None, - ) - .map_err(|error| RegistryError::Acl(error.to_string()))?; - } - Ok(Self { - descriptor, - _allocation: LocalAllocation(HLOCAL(descriptor.0)), - }) +fn read_access(requested: &RegistryAccessControlList) -> REG_SAM_FLAGS { + let mut access = REG_SAM_FLAGS(0); + if requested.discretionary.is_some() { + access |= READ_CONTROL; } + if requested.system.is_some() { + access |= ACCESS_SYSTEM_SECURITY; + } + access +} - fn permissions( - &self, - include_dacl_protection: bool, - ) -> Result<(OBJECT_SECURITY_INFORMATION, REG_SAM_FLAGS), RegistryError> { - let mut information = OBJECT_SECURITY_INFORMATION(0); - let mut access = 0; - let mut defaulted = BOOL::default(); - let mut owner = PSID::default(); - let mut group = PSID::default(); - let mut dacl = std::ptr::null_mut::(); - let mut sacl = std::ptr::null_mut::(); - let mut dacl_present = BOOL::default(); - let mut sacl_present = BOOL::default(); - - // SAFETY: The descriptor is valid for the lifetime of self, and all output pointers are - // writable local variables. - unsafe { - GetSecurityDescriptorOwner(self.descriptor, &mut owner, &mut defaulted) - .map_err(|error| RegistryError::Acl(error.to_string()))?; - GetSecurityDescriptorGroup(self.descriptor, &mut group, &mut defaulted) - .map_err(|error| RegistryError::Acl(error.to_string()))?; - GetSecurityDescriptorDacl( - self.descriptor, - &mut dacl_present, - &mut dacl, - &mut defaulted, - ) - .map_err(|error| RegistryError::Acl(error.to_string()))?; - GetSecurityDescriptorSacl( - self.descriptor, - &mut sacl_present, - &mut sacl, - &mut defaulted, - ) - .map_err(|error| RegistryError::Acl(error.to_string()))?; - } - - if sacl_present.as_bool() { - return Err(RegistryError::Acl(t!("error.saclUnsupported").to_string())); - } - if !owner.0.is_null() { - information |= OWNER_SECURITY_INFORMATION; - access |= 0x0008_0000; // WRITE_OWNER - } - if !group.0.is_null() { - information |= GROUP_SECURITY_INFORMATION; - access |= 0x0008_0000; // WRITE_OWNER - } - if dacl_present.as_bool() { - information |= DACL_SECURITY_INFORMATION; - access |= 0x0004_0000; // WRITE_DAC - if include_dacl_protection { - let mut control = 0_u16; - let mut revision = 0_u32; - // SAFETY: The descriptor is valid and both output pointers are writable. - unsafe { - GetSecurityDescriptorControl( - self.descriptor, - &mut control, - &mut revision, - ) - .map_err(|error| RegistryError::Acl(error.to_string()))?; - } - if control & SE_DACL_PROTECTED.0 != 0 { - information |= PROTECTED_DACL_SECURITY_INFORMATION; - } else { - information |= UNPROTECTED_DACL_SECURITY_INFORMATION; - } - } - } - if information == OBJECT_SECURITY_INFORMATION(0) { - return Err(RegistryError::Acl(t!("error.invalidAcl").to_string())); - } - Ok((information, REG_SAM_FLAGS(access))) +fn write_access(requested: &RegistryAccessControlList) -> REG_SAM_FLAGS { + let mut access = REG_SAM_FLAGS(0); + if requested.discretionary.is_some() { + access |= WRITE_DAC; + } + if requested.system.is_some() { + access |= ACCESS_SYSTEM_SECURITY; } + access } -fn open( - path: &str, - access: windows::Win32::System::Registry::REG_SAM_FLAGS, -) -> Result { +fn acl_error(error: lib_acl::AclError) -> RegistryError { + RegistryError::Acl(error.to_string()) +} + +fn open(path: &str, access: REG_SAM_FLAGS) -> Result { let (root, subkey) = split_path(path)?; let subkey = HSTRING::from(subkey); let mut key = HKEY::default(); @@ -277,56 +235,110 @@ fn win32_error(operation: &str, code: u32) -> RegistryError { #[cfg(test)] mod tests { use super::*; + use lib_acl::{AccessControlEntry, AceType, AuditFlag}; + + fn entry(ace_type: AceType) -> AccessControlEntry { + AccessControlEntry { + principal: "S-1-5-18".to_string(), + ace_type, + access_mask: vec![RegistryAccessRight::Read], + flags: Vec::new(), + audit_flags: Vec::new(), + } + } #[test] - fn permissions_detects_requested_sections() { - let descriptor = SecurityDescriptor::from_sddl("O:SYG:BAD:(A;;KA;;;SY)").unwrap(); - let (information, access) = descriptor.permissions(false).unwrap(); - - assert_eq!( - information.0, - OWNER_SECURITY_INFORMATION.0 - | GROUP_SECURITY_INFORMATION.0 - | DACL_SECURITY_INFORMATION.0 - ); - assert_eq!(access.0, 0x000c_0000); + fn read_access_reflects_requested_sections() { + let requested = RegistryAccessControlList { + discretionary: Some(vec![entry(AceType::Allow)]), + ..Default::default() + }; + assert_eq!(read_access(&requested), READ_CONTROL); } #[test] - fn permissions_preserves_dacl_protection() { - let descriptor = SecurityDescriptor::from_sddl("D:P(A;;KA;;;SY)").unwrap(); - let (information, access) = descriptor.permissions(true).unwrap(); - - assert_eq!( - information, - DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION - ); - assert_eq!(access.0, 0x0004_0000); + fn write_access_includes_system_security_for_sacl() { + let mut audit_entry = entry(AceType::Audit); + audit_entry.audit_flags = vec![AuditFlag::Success]; + let mut requested = RegistryAccessControlList { + system: Some(vec![audit_entry]), + ..Default::default() + }; + assert_eq!(write_access(&requested), ACCESS_SYSTEM_SECURITY); + + requested.discretionary = Some(vec![entry(AceType::Allow)]); + assert_eq!(write_access(&requested), WRITE_DAC | ACCESS_SYSTEM_SECURITY); } #[test] - fn permissions_ignores_component_markers_inside_conditional_aces() { - let descriptor = - SecurityDescriptor::from_sddl(r#"D:(XA;;KR;;;WD;(@User.Title == "O:operator"))"#) - .unwrap(); - let (information, access) = descriptor.permissions(false).unwrap(); - - assert_eq!(information, DACL_SECURITY_INFORMATION); - assert_eq!(access.0, 0x0004_0000); + fn validate_rejects_allow_entry_in_system_section() { + let requested = RegistryAccessControlList { + system: Some(vec![entry(AceType::Allow)]), + ..Default::default() + }; + let error = validate(&requested).unwrap_err(); + assert!(matches!(error, RegistryError::Acl(_))); } #[test] - fn permissions_rejects_sacl() { - let descriptor = SecurityDescriptor::from_sddl("S:(AU;SA;KA;;;WD)").unwrap(); - let error = descriptor.permissions(false).unwrap_err(); + fn validate_accepts_well_formed_discretionary_entry() { + let requested = RegistryAccessControlList { + discretionary: Some(vec![entry(AceType::Allow)]), + ..Default::default() + }; + validate(&requested).unwrap(); + } - assert!(matches!(error, RegistryError::Acl(_))); + #[test] + fn effective_with_purge_true_does_not_require_the_key_to_exist() { + // `_purge: true` (the default) never needs to read the key's current state, so this + // must succeed even though the key path below does not exist. + let requested = RegistryAccessControlList { + discretionary: Some(vec![entry(AceType::Allow)]), + purge: Some(true), + ..Default::default() + }; + + let effective_acl = effective(r"HKCU\DoesNotExist\AclPurgeTrue", &requested).unwrap(); + + // The well-known SID principal used by `entry` is already in canonical form, so + // normalizing it is a no-op and the result matches the request exactly. + assert_eq!(effective_acl.discretionary, requested.discretionary); } #[test] - fn validate_rejects_invalid_sddl() { - let error = validate("D:not-valid").unwrap_err(); + fn effective_merges_requested_entries_with_current_section_without_purge() { + // `HKCU\Software` always exists and already has a non-empty discretionary ACL; using it + // only for a read-only merge preview (never via `set`) keeps this test from touching any + // real permissions. + let key_path = r"HKCU\Software"; + let current = get( + key_path, + &RegistryAccessControlList { + discretionary: Some(Vec::new()), + ..Default::default() + }, + ) + .unwrap(); + assert!( + !current.discretionary.unwrap_or_default().is_empty(), + "expected HKCU\\Software to already have discretionary entries" + ); - assert!(matches!(error, RegistryError::Acl(_))); + let mut new_entry = entry(AceType::Allow); + new_entry.principal = "S-1-5-19".to_string(); // well-known LOCAL_SERVICE SID + let requested = RegistryAccessControlList { + discretionary: Some(vec![new_entry]), + purge: Some(false), + ..Default::default() + }; + + let effective_acl = effective(key_path, &requested).unwrap(); + let merged = effective_acl.discretionary.unwrap(); + let principals: Vec<&str> = merged.iter().map(|entry| entry.principal.as_str()).collect(); + + assert!(principals.contains(&"S-1-5-19")); + // The current, pre-existing entries are still present alongside the newly requested one. + assert!(merged.len() > 1); } } diff --git a/lib/dsc-lib-registry/src/config.rs b/lib/dsc-lib-registry/src/config.rs index 9ec3f5683..14a943b74 100644 --- a/lib/dsc-lib-registry/src/config.rs +++ b/lib/dsc-lib-registry/src/config.rs @@ -1,9 +1,102 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use lib_acl::{AccessControlList, AccessMask}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum RegistryAccessRight { + /// `KEY_QUERY_VALUE`: Required to query the values of a registry key. + QueryValue, + /// `KEY_SET_VALUE`: Required to create, delete, or set a registry value. + SetValue, + /// `KEY_CREATE_SUB_KEY`: Required to create a subkey of a registry key. + CreateSubKey, + /// `KEY_ENUMERATE_SUB_KEYS`: Required to enumerate the subkeys of a registry key. + EnumerateSubKeys, + /// `KEY_NOTIFY`: Required to request change notifications for a registry key or for + /// subkeys of a registry key. + Notify, + /// `KEY_CREATE_LINK`: Reserved for system use. + CreateLink, + /// `KEY_WOW64_64KEY`: Indicates that an application on 64-bit Windows should operate on + /// the 64-bit registry view. + Wow6464Key, + /// `KEY_WOW64_32KEY`: Indicates that an application on 64-bit Windows should operate on + /// the 32-bit registry view. + Wow6432Key, + /// `DELETE`: Required to delete the registry key. + Delete, + /// `READ_CONTROL`: Required to read the information in the registry key's security + /// descriptor, not including the information in the system access control list (SACL). + ReadPermissions, + /// `WRITE_DAC`: Required to modify the discretionary access control list (DACL) in the + /// registry key's security descriptor. + ChangePermissions, + /// `WRITE_OWNER`: Required to change the owner in the registry key's security descriptor. + TakeOwnership, + /// `SYNCHRONIZE`: The right to use the registry key for synchronization. + Synchronize, + /// `KEY_READ`: A composite of [`QueryValue`](Self::QueryValue), + /// [`EnumerateSubKeys`](Self::EnumerateSubKeys), [`Notify`](Self::Notify), and + /// [`ReadPermissions`](Self::ReadPermissions). + Read, + /// `KEY_WRITE`: A composite of [`SetValue`](Self::SetValue), + /// [`CreateSubKey`](Self::CreateSubKey), and [`ReadPermissions`](Self::ReadPermissions). + Write, + /// `KEY_EXECUTE`: Equivalent to [`Read`](Self::Read). + Execute, + /// `KEY_ALL_ACCESS`: A composite of every standard and registry-specific right. + FullControl, +} + +impl AccessMask for RegistryAccessRight { + fn bits(self) -> u32 { + match self { + Self::QueryValue => 0x0000_0001, + Self::SetValue => 0x0000_0002, + Self::CreateSubKey => 0x0000_0004, + Self::EnumerateSubKeys => 0x0000_0008, + Self::Notify => 0x0000_0010, + Self::CreateLink => 0x0000_0020, + Self::Wow6464Key => 0x0000_0100, + Self::Wow6432Key => 0x0000_0200, + Self::Delete => 0x0001_0000, + Self::ReadPermissions => 0x0002_0000, + Self::ChangePermissions => 0x0004_0000, + Self::TakeOwnership => 0x0008_0000, + Self::Synchronize => 0x0010_0000, + Self::Read | Self::Execute => 0x0002_0019, + Self::Write => 0x0002_0006, + Self::FullControl => 0x000F_003F, + } + } + + fn all() -> &'static [Self] { + &[ + Self::QueryValue, + Self::SetValue, + Self::CreateSubKey, + Self::EnumerateSubKeys, + Self::Notify, + Self::CreateLink, + Self::Wow6464Key, + Self::Wow6432Key, + Self::Delete, + Self::ReadPermissions, + Self::ChangePermissions, + Self::TakeOwnership, + Self::Synchronize, + Self::Read, + Self::Write, + Self::Execute, + Self::FullControl, + ] + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub enum RegistryValueData { String(String), @@ -30,9 +123,9 @@ pub struct Registry { /// The data of the registry value. #[serde(rename = "valueData", skip_serializing_if = "Option::is_none")] pub value_data: Option, - /// The registry key access control list in Security Descriptor Definition Language (SDDL). - #[serde(rename = "acl", skip_serializing_if = "Option::is_none")] - pub acl: Option, + /// The registry key's access control list (discretionary and/or system). + #[serde(rename = "accessControlList", skip_serializing_if = "Option::is_none")] + pub access_control_list: Option>, #[serde(rename = "_exist", skip_serializing_if = "Option::is_none")] pub exist: Option, /// Optional path to an offline registry hive file. When specified, operations @@ -45,5 +138,5 @@ pub struct Registry { #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct Metadata { #[serde(skip_serializing_if = "Option::is_none")] - pub what_if: Option> + pub what_if: Option>, } diff --git a/lib/dsc-lib-registry/src/lib.rs b/lib/dsc-lib-registry/src/lib.rs index 7ac41ac7d..44e1a852b 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -106,7 +106,7 @@ impl RegistryHelper { key_path: key_path.to_string(), value_name, value_data, - acl: None, + access_control_list: None, metadata: None, exist: None, registry_file_path: None, @@ -141,12 +141,12 @@ impl RegistryHelper { } if self.config.value_name.is_none() - && let Some(requested_acl) = &self.config.acl + && let Some(requested_acl) = &self.config.access_control_list { return match acl::get(&self.config.key_path, requested_acl) { Ok(current_acl) => Ok(Registry { key_path: self.config.key_path.clone(), - acl: Some(current_acl), + access_control_list: Some(current_acl), ..Default::default() }), Err(RegistryError::RegistryKeyNotFound(_)) => Ok(Registry { @@ -158,7 +158,7 @@ impl RegistryHelper { }; } - if let Some(requested_acl) = &self.config.acl { + if let Some(requested_acl) = &self.config.access_control_list { acl::validate(requested_acl)?; } @@ -177,7 +177,7 @@ impl RegistryHelper { }, Err(e) => return Err(e), }; - let acl = match &self.config.acl { + let access_control_list = match &self.config.access_control_list { Some(requested_acl) => Some(acl::get(&self.config.key_path, requested_acl)?), None => None, }; @@ -190,7 +190,7 @@ impl RegistryHelper { return Ok(Registry { key_path: self.config.key_path.clone(), value_name: Some(value_name.clone()), - acl, + access_control_list, exist: Some(exist), ..Default::default() }); @@ -202,13 +202,13 @@ impl RegistryHelper { key_path: self.config.key_path.clone(), value_name: Some(value_name.clone()), value_data: convert_reg_value(&value)?, - acl, + access_control_list, ..Default::default() }) } else { Ok(Registry { key_path: self.config.key_path.clone(), - acl, + access_control_list, ..Default::default() }) } @@ -256,29 +256,30 @@ impl RegistryHelper { /// * `RegistryError` - The error that occurred. pub fn set(&self) -> Result, RegistryError> { if self.offline_hive.is_some() { - if self.config.acl.is_some() { + if self.config.access_control_list.is_some() { return Err(RegistryError::OfflineRegistry( t!("error.offlineAcl").to_string(), )); } return self.set_offline(); } - if let Some(acl) = &self.config.acl { + if let Some(acl) = &self.config.access_control_list { acl::validate(acl)?; } let mut what_if_metadata: Vec = Vec::new(); if self.config.value_name.is_none() - && let Some(config_acl) = &self.config.acl + && let Some(config_acl) = &self.config.access_control_list && acl::can_set(&self.config.key_path, config_acl)? { if self.what_if { what_if_metadata.push( t!("registry_helper.whatIfSetAcl", key_path = &self.config.key_path).to_string(), ); + let effective_acl = acl::effective(&self.config.key_path, config_acl)?; return Ok(Some(Registry { key_path: self.config.key_path.clone(), - acl: Some(config_acl.clone()), + access_control_list: Some(effective_acl), metadata: Some(Metadata { what_if: Some(what_if_metadata) }), ..Default::default() })); @@ -366,7 +367,7 @@ impl RegistryHelper { }; if self.what_if { - if self.config.acl.is_some() { + if self.config.access_control_list.is_some() { what_if_metadata.push( t!("registry_helper.whatIfSetAcl", key_path = &self.config.key_path).to_string(), ); @@ -375,7 +376,7 @@ impl RegistryHelper { key_path: self.config.key_path.clone(), value_data: convert_reg_value(&data)?, value_name: self.config.value_name.clone(), - acl: self.config.acl.clone(), + access_control_list: self.config.access_control_list.clone(), metadata: if what_if_metadata.is_empty() { None } else { Some(Metadata { what_if: Some(what_if_metadata) })}, ..Default::default() })); @@ -387,20 +388,20 @@ impl RegistryHelper { } if self.what_if { - if self.config.acl.is_some() { + if self.config.access_control_list.is_some() { what_if_metadata.push( t!("registry_helper.whatIfSetAcl", key_path = &self.config.key_path).to_string(), ); } return Ok(Some(Registry { key_path: self.config.key_path.clone(), - acl: self.config.acl.clone(), + access_control_list: self.config.access_control_list.clone(), metadata: if what_if_metadata.is_empty() { None } else { Some(Metadata { what_if: Some(what_if_metadata) })}, ..Default::default() })); } - if let Some(acl) = &self.config.acl { + if let Some(acl) = &self.config.access_control_list { acl::set(&self.config.key_path, acl)?; } @@ -510,7 +511,7 @@ impl RegistryHelper { // --- Offline registry implementations --- fn get_offline(&self) -> Result { - if self.config.acl.is_some() { + if self.config.access_control_list.is_some() { return Err(RegistryError::OfflineRegistry( t!("error.offlineAcl").to_string(), )); diff --git a/lib/lib-acl/.project.data.json b/lib/lib-acl/.project.data.json new file mode 100644 index 000000000..202314aa3 --- /dev/null +++ b/lib/lib-acl/.project.data.json @@ -0,0 +1,5 @@ +{ + "Name": "lib-acl", + "Kind": "Library", + "IsRust": true +} diff --git a/lib/lib-acl/Cargo.toml b/lib/lib-acl/Cargo.toml new file mode 100644 index 000000000..8b3e1808f --- /dev/null +++ b/lib/lib-acl/Cargo.toml @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "lib-acl" +version = "0.1.0" +edition = "2024" + +[lib] +doctest = false + +[dependencies] +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true } diff --git a/lib/lib-acl/src/error.rs b/lib/lib-acl/src/error.rs new file mode 100644 index 000000000..eaa757b6d --- /dev/null +++ b/lib/lib-acl/src/error.rs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use thiserror::Error; + +/// Errors that can occur while modeling or applying an access control list. +#[derive(Debug, Error)] +#[allow(clippy::module_name_repetitions)] +pub enum AclError { + /// An access control entry's shape is invalid for the section it belongs to, for example an + /// `audit` entry in a discretionary list, or an `allow`/`deny` entry in a system list. + #[error("Invalid access control entry: {0}")] + InvalidEntry(String), + + /// The principal (SID string or account name) could not be resolved to a security + /// identifier. + #[error("Failed to resolve principal '{principal}' to a security identifier: {reason}")] + PrincipalResolution { principal: String, reason: String }, + + /// A value that was expected to be a SID string was not formatted correctly. + #[error("'{0}' is not a valid security identifier (SID) string")] + InvalidSid(String), + + /// A Win32 API call failed. + #[error("{operation} failed with error code {code}")] + Win32 { operation: &'static str, code: u32 }, + + /// The current process does not hold a privilege required to manage the system access + /// control list (SACL). + #[error( + "The '{0}' privilege is required to read or write the system access control list (SACL) \ + but is not held by the current process. Run with elevated (Administrator) rights and \ + ensure the account has the 'Manage auditing and security log' user right assigned." + )] + PrivilegeNotHeld(&'static str), + + /// An access control entry of an unsupported type (for example a conditional or + /// object-specific ACE) was encountered while reading an existing ACL. + #[error("Access control entry type {0} is not supported")] + UnsupportedAceType(u8), +} diff --git a/lib/lib-acl/src/lib.rs b/lib/lib-acl/src/lib.rs new file mode 100644 index 000000000..22cb0dae3 --- /dev/null +++ b/lib/lib-acl/src/lib.rs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A portable typed model for a Windows access control list (ACL), expressed as arrays of +//! access control entry (ACE) objects, along with Win32-backed operations to read and write it. +//! +//! The [`model`] module (DACL/SACL entries, access mask traits, flags) is available on every +//! platform so that resources can share the JSON shape and schema regardless of the host OS. +//! The [`win32`] module, which actually talks to Win32 APIs, is only compiled on Windows. + +pub mod error; +pub mod model; + +#[cfg(windows)] +pub mod win32; + +pub use error::AclError; +pub use model::{ + AccessControlEntry, AccessControlList, AccessMask, AceFlag, AceType, AuditFlag, bits_to_mask, + is_satisfied, mask_to_bits, merge_access_control_list, merge_entries, +}; diff --git a/lib/lib-acl/src/model.rs b/lib/lib-acl/src/model.rs new file mode 100644 index 000000000..f941f24a6 --- /dev/null +++ b/lib/lib-acl/src/model.rs @@ -0,0 +1,677 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Portable, resource-agnostic typed model for an access control list (ACL) expressed as +//! arrays of access control entry (ACE) objects. This module has no Win32 dependency so that +//! the shape of the model (and its JSON schema) is available on every platform; the Win32 +//! operations that actually read and write ACLs live in [`crate::win32`] and are only compiled +//! on Windows. + +use crate::error::AclError; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// A resource-specific access mask permission. Each resource (for example files or registry +/// keys) defines its own enum of the permissions it understands and implements this trait so +/// that [`AccessControlEntry`] can store a typed, human-readable `accessMask` array instead of a +/// raw bitmask. +pub trait AccessMask: Sized + Copy + Clone + Eq + 'static { + /// The raw access mask bit(s) represented by this value. Values may alias multiple bits + /// (for example a `FullControl` shorthand). + fn bits(self) -> u32; + + /// All known values, used to decompose a raw access mask back into a list of flags. + fn all() -> &'static [Self]; +} + +/// Combines a list of access mask values into a single raw bitmask. +#[must_use] +pub fn mask_to_bits(values: &[P]) -> u32 { + values.iter().fold(0_u32, |mask, value| mask | value.bits()) +} + +/// Decomposes a raw bitmask into a list of access mask values. Candidates with more bits are +/// preferred so that shorthand aliases (for example `FullControl`) are chosen over a long list +/// of individual atomic rights when they fit exactly. Bits that do not correspond to any known +/// value are dropped; round-tripping a mask through [`mask_to_bits`] and back is therefore not +/// guaranteed to reproduce the original list, only an equivalent mask for the bits this resource +/// understands. +#[must_use] +pub fn bits_to_mask(bits: u32) -> Vec

{ + if bits == 0 { + return Vec::new(); + } + + let mut candidates: Vec

= P::all().to_vec(); + candidates.sort_by_key(|b| core::cmp::Reverse(b.bits().count_ones())); + + let mut remaining = bits; + let mut result = Vec::new(); + for candidate in candidates { + let candidate_bits = candidate.bits(); + if candidate_bits != 0 && remaining & candidate_bits == candidate_bits { + result.push(candidate); + remaining &= !candidate_bits; + } + } + result +} + +/// The kind of access control entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AceType { + /// Grants the access mask to the principal. Valid only in a discretionary ACL. + Allow, + /// Denies the access mask to the principal. Valid only in a discretionary ACL. + Deny, + /// Generates audit events when the principal uses the access mask. Valid only in a system + /// ACL. + Audit, +} + +/// Inheritance and provenance flags for an access control entry. These correspond to the +/// `AceFlags` byte shared by every ACE header. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum AceFlag { + /// Containers (for example folders) beneath the object inherit the entry. + ContainerInherit, + /// Non-container objects (for example files) beneath the object inherit the entry. + ObjectInherit, + /// Inheritance does not propagate past the immediate children of the object. + NoPropagateInherit, + /// The entry does not apply to the object itself, only to objects that inherit it. + InheritOnly, + /// The entry was inherited from a parent container rather than set explicitly. + Inherited, +} + +/// Audit condition flags for a system (SACL) access control entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AuditFlag { + /// Generate an audit event when the access succeeds. + Success, + /// Generate an audit event when the access fails. + Failure, +} + +/// A single access control entry (ACE). +/// +/// `P` is the resource-specific access mask permission enum (for example the set of rights +/// understood by the filesystem or the registry). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AccessControlEntry

{ + /// The security principal the entry applies to. Accepts either a SID string (for example + /// `S-1-5-32-544`) or an account or group name (for example `DOMAIN\User` or + /// `Administrators`). Names are resolved to a security identifier; output always reports + /// the normalized SID string. + pub principal: String, + /// Whether the entry allows, denies, or audits access. + #[serde(rename = "type")] + pub ace_type: AceType, + /// The permissions granted, denied, or audited by this entry. + pub access_mask: Vec

, + /// Inheritance flags for the entry. Defaults to an empty list (no inheritance). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub flags: Vec, + /// The audit conditions for the entry. Required (and non-empty) when `type` is `audit`; + /// must be empty otherwise. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub audit_flags: Vec, +} + +impl AccessControlEntry

{ + /// Validates that this entry is well-formed for inclusion in a discretionary ACL (DACL). + /// + /// # Errors + /// + /// Returns [`AclError::InvalidEntry`] when the entry's type or flags are not valid for a + /// discretionary ACL. + pub fn validate_discretionary(&self) -> Result<(), AclError> { + if !matches!(self.ace_type, AceType::Allow | AceType::Deny) { + return Err(AclError::InvalidEntry( + "discretionary access control entries must have type 'allow' or 'deny'".to_string(), + )); + } + if !self.audit_flags.is_empty() { + return Err(AclError::InvalidEntry( + "discretionary access control entries must not set auditFlags".to_string(), + )); + } + if self.access_mask.is_empty() { + return Err(AclError::InvalidEntry( + "an access control entry must specify at least one accessMask value".to_string(), + )); + } + Ok(()) + } + + /// Validates that this entry is well-formed for inclusion in a system ACL (SACL). + /// + /// # Errors + /// + /// Returns [`AclError::InvalidEntry`] when the entry's type or flags are not valid for a + /// system ACL. + pub fn validate_system(&self) -> Result<(), AclError> { + if self.ace_type != AceType::Audit { + return Err(AclError::InvalidEntry( + "system access control entries must have type 'audit'".to_string(), + )); + } + if self.audit_flags.is_empty() { + return Err(AclError::InvalidEntry( + "system access control entries must set at least one auditFlags value".to_string(), + )); + } + if self.access_mask.is_empty() { + return Err(AclError::InvalidEntry( + "an access control entry must specify at least one accessMask value".to_string(), + )); + } + Ok(()) + } +} + +/// The access control list (ACL) for an object, expressed as arrays of access control entries. +/// +/// Both `discretionary` (DACL) and `system` (SACL) sections are optional so that callers can +/// select which section(s) they want to manage; a section that is omitted is left untouched. +/// +/// `P` is the resource-specific access mask permission enum (for example the set of rights +/// understood by the filesystem or the registry). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AccessControlList

{ + /// The discretionary access control list (DACL) entries. Omit to leave the DACL unmanaged. + #[serde(skip_serializing_if = "Option::is_none")] + pub discretionary: Option>>, + /// Whether the DACL is protected from inheriting entries from its parent. Omit to leave the + /// current protection state unchanged when `discretionary` is not provided, or to request + /// an unprotected (inheriting) DACL when it is. + #[serde(skip_serializing_if = "Option::is_none")] + pub discretionary_protected: Option, + /// The system access control list (SACL) entries. Omit to leave the SACL unmanaged. + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option>>, + /// Whether the SACL is protected from inheriting entries from its parent. Omit to leave the + /// current protection state unchanged when `system` is not provided, or to request an + /// unprotected (inheriting) SACL when it is. + #[serde(skip_serializing_if = "Option::is_none")] + pub system_protected: Option, + /// Whether to replace (purge) each supplied section outright, or merge its entries into the + /// section's current managed entries. Defaults to `true` (replace) when omitted, which + /// preserves the historical full-replacement behavior. When `false`, the entries supplied + /// for a section are merged into that section's existing entries: semantic duplicates (same + /// normalized principal, ACE type, effective access mask, and inheritance/audit flags) are + /// not duplicated, and the resulting discretionary section is canonicalized with every + /// `deny` entry before every `allow` entry. A section that is omitted from the request + /// remains unmanaged regardless of this setting. + #[serde(rename = "_purge", skip_serializing_if = "Option::is_none")] + #[schemars(default = "default_purge")] + pub purge: Option, +} + +fn default_purge() -> Option { + Some(true) +} + +impl

Default for AccessControlList

{ + fn default() -> Self { + Self { + discretionary: None, + discretionary_protected: None, + system: None, + system_protected: None, + purge: None, + } + } +} + +impl AccessControlList

{ + /// Validates every entry present in the list against the section it belongs to. + /// + /// # Errors + /// + /// Returns [`AclError::InvalidEntry`] when any entry is not valid for its section. + pub fn validate(&self) -> Result<(), AclError> { + if let Some(entries) = &self.discretionary { + for entry in entries { + entry.validate_discretionary()?; + } + } + if let Some(entries) = &self.system { + for entry in entries { + entry.validate_system()?; + } + } + Ok(()) + } + + /// The effective value of `purge`: `true` (replace) when not explicitly specified. + #[must_use] + pub fn purge(&self) -> bool { + self.purge.unwrap_or(true) + } +} + +/// A normalized key identifying whether two access control entries are semantic duplicates: +/// an equal principal (expected to already be normalized, for example a resolved SID string), +/// an equal ACE type, an equal effective access mask (decomposed to its raw bits so that +/// aliased permission names, such as a `fullControl` shorthand versus the individual rights it +/// implies, still compare equal), equal inheritance flags (ignoring [`AceFlag::Inherited`], +/// which reflects only provenance and is never meaningful in a request), and equal audit flags. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct AceSemanticKey { + principal: String, + ace_type: AceType, + mask_bits: u32, + flags: Vec, + audit_flags: Vec, +} + +fn semantic_key(entry: &AccessControlEntry

) -> AceSemanticKey { + let mut flags: Vec = entry + .flags + .iter() + .copied() + .filter(|flag| *flag != AceFlag::Inherited) + .collect(); + flags.sort_unstable(); + flags.dedup(); + + let mut audit_flags = entry.audit_flags.clone(); + audit_flags.sort_unstable(); + audit_flags.dedup(); + + AceSemanticKey { + principal: entry.principal.clone(), + ace_type: entry.ace_type, + mask_bits: mask_to_bits(&entry.access_mask), + flags, + audit_flags, + } +} + +/// Merges `requested` access control entries into `current`, dropping any requested entry that +/// is a semantic duplicate (see [`AceSemanticKey`]) of an entry already present in `current` or +/// earlier in `requested`, and then canonicalizing the result so that every `deny` entry +/// precedes every `allow`/`audit` entry, preserving the relative order of entries within each +/// group. Both inputs are expected to already have normalized (for example resolved-SID) +/// principals. +#[must_use] +pub fn merge_entries( + current: &[AccessControlEntry

], + requested: &[AccessControlEntry

], +) -> Vec> { + let mut seen = HashSet::with_capacity(current.len() + requested.len()); + let mut merged = Vec::with_capacity(current.len() + requested.len()); + for entry in current.iter().chain(requested.iter()) { + if seen.insert(semantic_key(entry)) { + merged.push(entry.clone()); + } + } + // `sort_by_key` is a stable sort, so entries within the `deny` group and within the + // `allow`/`audit` group keep their relative order. + merged.sort_by_key(|entry| entry.ace_type != AceType::Deny); + merged +} + +/// Computes the effective access control list that should be written (or compared against) for +/// `requested`, given its [`AccessControlList::purge`] setting and the `current` state of the +/// section(s) `requested` manages. Both `current` and `requested` are expected to already have +/// normalized (for example resolved-SID) principals. +/// +/// When `purge` is `true` (the default), each supplied section is replaced outright: the result +/// is `requested`'s section verbatim. When `purge` is `false`, each supplied section is merged +/// with the corresponding section of `current` (see [`merge_entries`]); a protection flag that +/// is not explicitly supplied in `requested` preserves `current`'s value instead of resetting it. +/// +/// A section that is not present (`None`) in `requested` is left unmanaged and is always `None` +/// in the result, regardless of `current`. +#[must_use] +pub fn merge_access_control_list( + current: &AccessControlList

, + requested: &AccessControlList

, +) -> AccessControlList

{ + let purge = requested.purge(); + + let discretionary = requested.discretionary.as_ref().map(|entries| { + if purge { + entries.clone() + } else { + merge_entries(current.discretionary.as_deref().unwrap_or(&[]), entries) + } + }); + let discretionary_protected = requested.discretionary.as_ref().and_then(|_| { + if purge { + requested.discretionary_protected + } else { + requested + .discretionary_protected + .or(current.discretionary_protected) + } + }); + + let system = requested.system.as_ref().map(|entries| { + if purge { + entries.clone() + } else { + merge_entries(current.system.as_deref().unwrap_or(&[]), entries) + } + }); + let system_protected = requested.system.as_ref().and_then(|_| { + if purge { + requested.system_protected + } else { + requested.system_protected.or(current.system_protected) + } + }); + + AccessControlList { + discretionary, + discretionary_protected, + system, + system_protected, + purge: requested.purge, + } +} + +/// Returns whether `actual` satisfies `desired`, an access control list that has already been +/// passed through [`merge_access_control_list`] (so that, when `desired`'s +/// [`purge`](AccessControlList::purge) is `false`, it reflects the full effective merge rather +/// than only the partial, newly requested entries). +/// +/// A section that is `None` in `desired` is always satisfied (it is unmanaged). When `desired`'s +/// `purge` is `true` (the default), a managed section must match `actual` exactly, including +/// order, mirroring the full-replacement semantics used when writing it. When `false`, it is +/// enough for `actual` to contain a semantic duplicate (see [`AceSemanticKey`]) of every entry in +/// `desired`; additional entries present only in `actual` do not cause a mismatch. A protection +/// flag that is not explicitly supplied in `desired` is always considered satisfied. +#[must_use] +pub fn is_satisfied(desired: &AccessControlList

, actual: &AccessControlList

) -> bool { + let purge = desired.purge(); + section_satisfied(purge, &desired.discretionary, &actual.discretionary) + && protected_satisfied(desired.discretionary_protected, actual.discretionary_protected) + && section_satisfied(purge, &desired.system, &actual.system) + && protected_satisfied(desired.system_protected, actual.system_protected) +} + +fn section_satisfied( + purge: bool, + desired: &Option>>, + actual: &Option>>, +) -> bool { + let Some(desired_entries) = desired else { + return true; + }; + let Some(actual_entries) = actual else { + return false; + }; + if purge { + desired_entries == actual_entries + } else { + let desired_keys: HashSet<_> = desired_entries.iter().map(semantic_key).collect(); + let actual_keys: HashSet<_> = actual_entries.iter().map(semantic_key).collect(); + desired_keys == actual_keys + } +} + +fn protected_satisfied(desired: Option, actual: Option) -> bool { + match desired { + None => true, + Some(value) => actual == Some(value), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum TestRight { + Read, + Write, + FullControl, + } + + impl AccessMask for TestRight { + fn bits(self) -> u32 { + match self { + Self::Read => 0x1, + Self::Write => 0x2, + // Aliases the combination of Read and Write, the same way `fullControl` aliases + // several atomic rights for real resources. + Self::FullControl => 0x3, + } + } + + fn all() -> &'static [Self] { + &[Self::Read, Self::Write, Self::FullControl] + } + } + + fn entry( + principal: &str, + ace_type: AceType, + access_mask: Vec, + ) -> AccessControlEntry { + AccessControlEntry { + principal: principal.to_string(), + ace_type, + access_mask, + flags: Vec::new(), + audit_flags: Vec::new(), + } + } + + #[test] + fn purge_defaults_to_true_when_omitted() { + let acl = AccessControlList:: { + discretionary: Some(Vec::new()), + ..Default::default() + }; + assert!(acl.purge.is_none()); + assert!(acl.purge()); + } + + #[test] + fn purge_reflects_explicit_false() { + let acl = AccessControlList:: { + discretionary: Some(Vec::new()), + purge: Some(false), + ..Default::default() + }; + assert!(!acl.purge()); + } + + #[test] + fn purge_true_replaces_the_section_outright() { + let current = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + ..Default::default() + }; + let requested = AccessControlList { + discretionary: Some(vec![entry("S-1-1-1", AceType::Allow, vec![TestRight::Write])]), + purge: Some(true), + ..Default::default() + }; + + let effective = merge_access_control_list(¤t, &requested); + + assert_eq!(effective.discretionary, requested.discretionary); + } + + #[test] + fn purge_false_merges_new_entries_with_current_ones() { + let current = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + ..Default::default() + }; + let requested = AccessControlList { + discretionary: Some(vec![entry("S-1-1-1", AceType::Allow, vec![TestRight::Write])]), + purge: Some(false), + ..Default::default() + }; + + let effective = merge_access_control_list(¤t, &requested); + + let principals: Vec<&str> = effective + .discretionary + .as_ref() + .unwrap() + .iter() + .map(|entry| entry.principal.as_str()) + .collect(); + assert_eq!(principals, vec!["S-1-1-0", "S-1-1-1"]); + } + + #[test] + fn merge_drops_semantic_duplicates_by_effective_access_mask() { + // FullControl (0x3) is the same effective mask as Read (0x1) + Write (0x2) combined, so + // an entry requesting FullControl is a duplicate of two existing entries that together + // grant the same bits... but a single current entry already granting FullControl must + // also be recognized as a duplicate of a request for the equivalent alias. + let current = vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::FullControl])]; + let requested = vec![entry( + "S-1-1-0", + AceType::Allow, + vec![TestRight::Read, TestRight::Write], + )]; + + let merged = merge_entries(¤t, &requested); + + assert_eq!(merged.len(), 1); + } + + #[test] + fn merge_drops_duplicates_differing_only_by_inherited_flag() { + let mut inherited = entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]); + inherited.flags = vec![AceFlag::Inherited]; + let current = vec![inherited]; + // The same entry, requested explicitly (never carries the Inherited flag). + let requested = vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]; + + let merged = merge_entries(¤t, &requested); + + assert_eq!(merged.len(), 1); + // The existing, inherited entry is preserved rather than being replaced. + assert!(merged[0].flags.contains(&AceFlag::Inherited)); + } + + #[test] + fn merge_orders_deny_entries_before_allow_entries_preserving_relative_order() { + let current = vec![ + entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]), + entry("S-1-1-1", AceType::Deny, vec![TestRight::Write]), + ]; + let requested = vec![ + entry("S-1-1-2", AceType::Allow, vec![TestRight::Read]), + entry("S-1-1-3", AceType::Deny, vec![TestRight::Write]), + ]; + + let merged = merge_entries(¤t, &requested); + + let principals: Vec<&str> = merged.iter().map(|entry| entry.principal.as_str()).collect(); + // Both deny entries precede both allow entries; within each group, entries keep their + // original relative order (current before newly requested). + assert_eq!( + principals, + vec!["S-1-1-1", "S-1-1-3", "S-1-1-0", "S-1-1-2"] + ); + } + + #[test] + fn is_satisfied_requires_exact_match_when_purge_is_true() { + let actual = AccessControlList { + discretionary: Some(vec![ + entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]), + entry("S-1-1-1", AceType::Allow, vec![TestRight::Write]), + ]), + ..Default::default() + }; + let desired = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + purge: Some(true), + ..Default::default() + }; + + // Extra entries in `actual` are not tolerated when purge is true. + assert!(!is_satisfied(&desired, &actual)); + } + + #[test] + fn is_satisfied_allows_extra_entries_when_purge_is_false() { + let actual = AccessControlList { + discretionary: Some(vec![ + entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]), + entry("S-1-1-1", AceType::Allow, vec![TestRight::Write]), + ]), + ..Default::default() + }; + let desired = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + purge: Some(false), + ..Default::default() + }; + let effective_desired = merge_access_control_list(&actual, &desired); + + assert!(is_satisfied(&effective_desired, &actual)); + } + + #[test] + fn is_satisfied_is_false_when_purge_is_false_and_an_entry_is_missing() { + let actual = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + ..Default::default() + }; + let desired = AccessControlList { + discretionary: Some(vec![entry("S-1-1-1", AceType::Allow, vec![TestRight::Write])]), + purge: Some(false), + ..Default::default() + }; + let effective_desired = merge_access_control_list(&actual, &desired); + + assert!(!is_satisfied(&effective_desired, &actual)); + } + + #[test] + fn protection_flag_is_preserved_when_omitted_during_merge() { + let current = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + discretionary_protected: Some(true), + ..Default::default() + }; + let requested = AccessControlList { + discretionary: Some(vec![entry("S-1-1-1", AceType::Allow, vec![TestRight::Write])]), + purge: Some(false), + ..Default::default() + }; + + let effective = merge_access_control_list(¤t, &requested); + + assert_eq!(effective.discretionary_protected, Some(true)); + } + + #[test] + fn protection_flag_explicitly_supplied_overrides_current_during_merge() { + let current = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + discretionary_protected: Some(true), + ..Default::default() + }; + let requested = AccessControlList { + discretionary: Some(vec![entry("S-1-1-1", AceType::Allow, vec![TestRight::Write])]), + discretionary_protected: Some(false), + purge: Some(false), + ..Default::default() + }; + + let effective = merge_access_control_list(¤t, &requested); + + assert_eq!(effective.discretionary_protected, Some(false)); + } +} diff --git a/lib/lib-acl/src/win32/acl.rs b/lib/lib-acl/src/win32/acl.rs new file mode 100644 index 000000000..5c9f4457a --- /dev/null +++ b/lib/lib-acl/src/win32/acl.rs @@ -0,0 +1,244 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Builds and parses Windows ACL structures (arrays of ACE objects) from and to the portable +//! [`crate::model`] types. + +use crate::error::AclError; +use crate::model::{ + AccessControlEntry, AccessMask, AceFlag, AceType, AuditFlag, bits_to_mask, mask_to_bits, +}; +use crate::win32::sid::{OwnedSid, resolve_principal, sid_to_string}; +use core::ffi::c_void; +use core::mem::size_of; +use windows::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACCESS_DENIED_ACE, ACE_FLAGS, ACL, ACL_REVISION, ACL_SIZE_INFORMATION, + AclSizeInformation, AddAccessAllowedAceEx, AddAccessDeniedAceEx, AddAuditAccessAceEx, + CONTAINER_INHERIT_ACE, GetAce, GetAclInformation, INHERIT_ONLY_ACE, INHERITED_ACE, + InitializeAcl, NO_PROPAGATE_INHERIT_ACE, OBJECT_INHERIT_ACE, SYSTEM_AUDIT_ACE, +}; + +// These ACE type identifiers live in `windows::Win32::System::SystemServices` upstream, which +// would require pulling in an additional `windows` crate feature just for three well-known, +// stable constants; they are reproduced here instead. +const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; +const ACCESS_DENIED_ACE_TYPE: u8 = 1; +const SYSTEM_AUDIT_ACE_TYPE: u8 = 2; + +/// An owned, ready-to-attach ACL buffer built from a list of access control entries. +pub struct AclBuffer { + bytes: Vec, +} + +impl AclBuffer { + /// Returns a pointer to the built ACL, valid for as long as this buffer is alive. + pub fn as_ptr(&mut self) -> *mut ACL { + self.bytes.as_mut_ptr().cast::() + } + + /// Builds an ACL from a list of access control entries. + /// + /// # Errors + /// + /// Returns [`AclError`] when a principal cannot be resolved, or when a lower-level Win32 API + /// call fails. + pub fn build(entries: &[AccessControlEntry

]) -> Result { + let mut resolved: Vec<(&AccessControlEntry

, OwnedSid)> = + Vec::with_capacity(entries.len()); + // ACE layout for every ACE type this crate emits is header (4 bytes) + mask (4 bytes) + + // SID bytes, which are always DWORD-aligned, so no additional padding is required. + let mut total_size = size_of::(); + for entry in entries { + let sid = resolve_principal(&entry.principal)?; + total_size += 8 + sid_byte_len(&sid); + resolved.push((entry, sid)); + } + + let mut bytes = vec![0_u8; total_size]; + let acl_ptr = bytes.as_mut_ptr().cast::(); + // SAFETY: bytes is sized to hold the ACL header plus every ACE that will be added below. + unsafe { InitializeAcl(acl_ptr, total_size as u32, ACL_REVISION) }.map_err(|error| { + AclError::Win32 { + operation: "InitializeAcl", + code: error.code().0 as u32, + } + })?; + + for (entry, sid) in &resolved { + let ace_flags = ACE_FLAGS(inheritance_flags_to_bits(&entry.flags)); + let mask = mask_to_bits(&entry.access_mask); + let psid = sid.as_psid(); + + let result = match entry.ace_type { + // SAFETY: acl_ptr was initialized above and has enough reserved space for this + // ACE (accounted for in total_size); psid is valid for the duration of this call. + AceType::Allow => unsafe { + AddAccessAllowedAceEx(acl_ptr, ACL_REVISION, ace_flags, mask, psid) + }, + AceType::Deny => unsafe { + AddAccessDeniedAceEx(acl_ptr, ACL_REVISION, ace_flags, mask, psid) + }, + AceType::Audit => { + let success = entry.audit_flags.contains(&AuditFlag::Success); + let failure = entry.audit_flags.contains(&AuditFlag::Failure); + unsafe { + AddAuditAccessAceEx( + acl_ptr, + ACL_REVISION, + ace_flags, + mask, + psid, + success, + failure, + ) + } + } + }; + result.map_err(|error| AclError::Win32 { + operation: "AddAce", + code: error.code().0 as u32, + })?; + } + + Ok(Self { bytes }) + } +} + +fn sid_byte_len(sid: &OwnedSid) -> usize { + // SAFETY: sid owns a valid SID buffer; GetLengthSid only reads the SID header. + unsafe { windows::Win32::Security::GetLengthSid(sid.as_psid()) as usize } +} + +fn inheritance_flags_to_bits(flags: &[AceFlag]) -> u32 { + flags.iter().fold(0_u32, |bits, flag| { + bits | match flag { + AceFlag::ContainerInherit => CONTAINER_INHERIT_ACE.0, + AceFlag::ObjectInherit => OBJECT_INHERIT_ACE.0, + AceFlag::NoPropagateInherit => NO_PROPAGATE_INHERIT_ACE.0, + AceFlag::InheritOnly => INHERIT_ONLY_ACE.0, + AceFlag::Inherited => INHERITED_ACE.0, + } + }) +} + +fn inheritance_flags_from_bits(bits: u32) -> Vec { + let mut flags = Vec::new(); + if bits & CONTAINER_INHERIT_ACE.0 != 0 { + flags.push(AceFlag::ContainerInherit); + } + if bits & OBJECT_INHERIT_ACE.0 != 0 { + flags.push(AceFlag::ObjectInherit); + } + if bits & NO_PROPAGATE_INHERIT_ACE.0 != 0 { + flags.push(AceFlag::NoPropagateInherit); + } + if bits & INHERIT_ONLY_ACE.0 != 0 { + flags.push(AceFlag::InheritOnly); + } + if bits & INHERITED_ACE.0 != 0 { + flags.push(AceFlag::Inherited); + } + flags +} + +fn audit_flags_from_bits(bits: u32) -> Vec { + const SUCCESSFUL_ACCESS_ACE_FLAG: u32 = 0x40; + const FAILED_ACCESS_ACE_FLAG: u32 = 0x80; + + let mut flags = Vec::new(); + if bits & SUCCESSFUL_ACCESS_ACE_FLAG != 0 { + flags.push(AuditFlag::Success); + } + if bits & FAILED_ACCESS_ACE_FLAG != 0 { + flags.push(AuditFlag::Failure); + } + flags +} + +/// Parses an existing ACL into a list of access control entries. +/// +/// # Errors +/// +/// Returns [`AclError::UnsupportedAceType`] when the ACL contains an entry type this crate does +/// not model (for example an object-specific or conditional ACE), or [`AclError`] when a +/// lower-level Win32 API call fails. +pub(crate) fn parse_acl( + acl_ptr: *mut ACL, +) -> Result>, AclError> { + let mut size_info = ACL_SIZE_INFORMATION::default(); + // SAFETY: acl_ptr is a valid, initialized ACL pointer; size_info is a writable local + // variable of the correct size for AclSizeInformation. + unsafe { + GetAclInformation( + acl_ptr, + (&raw mut size_info).cast::(), + size_of::() as u32, + AclSizeInformation, + ) + } + .map_err(|error| AclError::Win32 { + operation: "GetAclInformation", + code: error.code().0 as u32, + })?; + + let mut entries = Vec::with_capacity(size_info.AceCount as usize); + for index in 0..size_info.AceCount { + let mut ace_ptr: *mut c_void = core::ptr::null_mut(); + // SAFETY: acl_ptr is valid and index is within [0, AceCount). + unsafe { GetAce(acl_ptr, index, &mut ace_ptr) }.map_err(|error| AclError::Win32 { + operation: "GetAce", + code: error.code().0 as u32, + })?; + + // SAFETY: every ACE begins with an ACE_HEADER; GetAce returned a valid pointer. + let header = unsafe { &*ace_ptr.cast::() }; + let ace_flags_bits = u32::from(header.AceFlags); + + let (mask, sid_ptr, ace_type) = if header.AceType == ACCESS_ALLOWED_ACE_TYPE { + // SAFETY: header.AceType identifies this ACE as an ACCESS_ALLOWED_ACE. + let ace = unsafe { &*ace_ptr.cast::() }; + ( + ace.Mask, + core::ptr::addr_of!(ace.SidStart).cast::(), + AceType::Allow, + ) + } else if header.AceType == ACCESS_DENIED_ACE_TYPE { + // SAFETY: header.AceType identifies this ACE as an ACCESS_DENIED_ACE. + let ace = unsafe { &*ace_ptr.cast::() }; + ( + ace.Mask, + core::ptr::addr_of!(ace.SidStart).cast::(), + AceType::Deny, + ) + } else if header.AceType == SYSTEM_AUDIT_ACE_TYPE { + // SAFETY: header.AceType identifies this ACE as a SYSTEM_AUDIT_ACE. + let ace = unsafe { &*ace_ptr.cast::() }; + ( + ace.Mask, + core::ptr::addr_of!(ace.SidStart).cast::(), + AceType::Audit, + ) + } else { + return Err(AclError::UnsupportedAceType(header.AceType)); + }; + + let principal = sid_to_string(windows::Win32::Security::PSID(sid_ptr.cast_mut()))?; + let flags = inheritance_flags_from_bits(ace_flags_bits); + let audit_flags = if ace_type == AceType::Audit { + audit_flags_from_bits(ace_flags_bits) + } else { + Vec::new() + }; + let access_mask = bits_to_mask::

(mask); + + entries.push(AccessControlEntry { + principal, + ace_type, + access_mask, + flags, + audit_flags, + }); + } + + Ok(entries) +} diff --git a/lib/lib-acl/src/win32/mod.rs b/lib/lib-acl/src/win32/mod.rs new file mode 100644 index 000000000..93e624d38 --- /dev/null +++ b/lib/lib-acl/src/win32/mod.rs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Win32-backed operations for reading and writing access control lists. Only compiled on +//! Windows; see [`crate::model`] for the portable typed model these operations read and write. + +pub mod acl; +pub mod privilege; +pub mod security_descriptor; +pub mod sid; + +pub use privilege::{SE_SECURITY_NAME, enable_security_privilege}; +pub use security_descriptor::{ + build_self_relative_security_descriptor, normalize, read_access_control_list, + read_security_information, +}; +pub use sid::{resolve_principal, sid_to_string}; diff --git a/lib/lib-acl/src/win32/privilege.rs b/lib/lib-acl/src/win32/privilege.rs new file mode 100644 index 000000000..4b89150fb --- /dev/null +++ b/lib/lib-acl/src/win32/privilege.rs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Enables the `SeSecurityPrivilege` privilege ("Manage auditing and security log") on the +//! current process's token, which is required before reading or writing a system access control +//! list (SACL). + +use crate::error::AclError; +use windows::Win32::Foundation::{CloseHandle, ERROR_NOT_ALL_ASSIGNED, GetLastError, HANDLE, LUID}; +use windows::Win32::Security::{ + AdjustTokenPrivileges, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, SE_PRIVILEGE_ENABLED, + TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, TOKEN_QUERY, +}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +use windows::core::{HSTRING, PCWSTR}; + +/// The name of the privilege required to read or write a system access control list (SACL). +pub const SE_SECURITY_NAME: &str = "SeSecurityPrivilege"; + +/// Enables [`SE_SECURITY_NAME`] on the current process's token. +/// +/// # Errors +/// +/// Returns [`AclError::PrivilegeNotHeld`] when the current process's token does not hold the +/// privilege (for example because the account lacks the "Manage auditing and security log" user +/// right), or [`AclError::Win32`] when a lower-level API call fails. +pub fn enable_security_privilege() -> Result<(), AclError> { + // SAFETY: GetCurrentProcess returns a pseudo-handle that does not need to be closed. + let process = unsafe { GetCurrentProcess() }; + let mut token = HANDLE::default(); + // SAFETY: process is a valid pseudo-handle; token is a writable local variable that + // receives ownership of the returned handle, closed below. + unsafe { OpenProcessToken(process, TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &mut token) } + .map_err(|error| AclError::Win32 { + operation: "OpenProcessToken", + code: error.code().0 as u32, + })?; + + let result = enable_privilege_on_token(token, SE_SECURITY_NAME); + // SAFETY: token was opened above and is owned by this function. + unsafe { + let _ = CloseHandle(token); + } + result +} + +fn enable_privilege_on_token(token: HANDLE, name: &'static str) -> Result<(), AclError> { + let wide = HSTRING::from(name); + let mut luid = LUID::default(); + // SAFETY: wide is a valid null-terminated string for the duration of this call. + unsafe { LookupPrivilegeValueW(PCWSTR::null(), PCWSTR(wide.as_ptr()), &mut luid) }.map_err( + |error| AclError::Win32 { + operation: "LookupPrivilegeValueW", + code: error.code().0 as u32, + }, + )?; + + let privileges = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + + // SAFETY: token is a valid, open token handle with TOKEN_ADJUST_PRIVILEGES access, and + // privileges is a validly constructed TOKEN_PRIVILEGES describing a single privilege. + let adjust_result = + unsafe { AdjustTokenPrivileges(token, false, Some(&raw const privileges), 0, None, None) }; + // AdjustTokenPrivileges can report success while silently not assigning any privileges; the + // only reliable signal is the last error code, which must be read immediately after the call. + // SAFETY: GetLastError reads thread-local state set by the call above. + let not_all_assigned = unsafe { GetLastError() } == ERROR_NOT_ALL_ASSIGNED; + + adjust_result.map_err(|error| AclError::Win32 { + operation: "AdjustTokenPrivileges", + code: error.code().0 as u32, + })?; + if not_all_assigned { + return Err(AclError::PrivilegeNotHeld(name)); + } + Ok(()) +} diff --git a/lib/lib-acl/src/win32/security_descriptor.rs b/lib/lib-acl/src/win32/security_descriptor.rs new file mode 100644 index 000000000..d882b158e --- /dev/null +++ b/lib/lib-acl/src/win32/security_descriptor.rs @@ -0,0 +1,309 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Builds a self-relative security descriptor byte buffer from the portable [`crate::model`] +//! types, and reads the DACL/SACL and protection state back out of an existing (self-relative) +//! security descriptor buffer such as one returned by `RegGetKeySecurity` or +//! `GetFileSecurityW`. + +use crate::error::AclError; +use crate::model::{AccessControlEntry, AccessControlList, AccessMask}; +use crate::win32::acl::{AclBuffer, parse_acl}; +use crate::win32::sid::{resolve_principal, sid_to_string}; +use core::ffi::c_void; +use windows::Win32::Security::{ + DACL_SECURITY_INFORMATION, GetSecurityDescriptorControl, GetSecurityDescriptorDacl, + GetSecurityDescriptorSacl, InitializeSecurityDescriptor, MakeSelfRelativeSD, + OBJECT_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + PROTECTED_SACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SACL_SECURITY_INFORMATION, + SE_DACL_PROTECTED, SE_SACL_PROTECTED, SECURITY_DESCRIPTOR, SECURITY_DESCRIPTOR_CONTROL, + SetSecurityDescriptorControl, SetSecurityDescriptorDacl, SetSecurityDescriptorSacl, + UNPROTECTED_DACL_SECURITY_INFORMATION, UNPROTECTED_SACL_SECURITY_INFORMATION, +}; + +// `SECURITY_DESCRIPTOR_REVISION` lives in `windows::Win32::System::SystemServices` upstream, +// which would require pulling in an additional `windows` crate feature just for this one +// well-known, stable constant; it is reproduced here instead. +const SECURITY_DESCRIPTOR_REVISION: u32 = 1; + +/// The `SECURITY_INFORMATION` bits identifying which sections a read request wants. +#[must_use] +pub fn read_security_information( + want_discretionary: bool, + want_system: bool, +) -> OBJECT_SECURITY_INFORMATION { + let mut information = OBJECT_SECURITY_INFORMATION(0); + if want_discretionary { + information |= DACL_SECURITY_INFORMATION; + } + if want_system { + information |= SACL_SECURITY_INFORMATION; + } + information +} + +/// Reads the requested sections of an existing, self-relative security descriptor buffer into +/// the portable model. +/// +/// # Errors +/// +/// Returns [`AclError`] when the descriptor is malformed or contains an unsupported ACE type. +pub fn read_access_control_list( + descriptor_bytes: &mut [u8], + want_discretionary: bool, + want_system: bool, +) -> Result, AclError> { + let descriptor = PSECURITY_DESCRIPTOR(descriptor_bytes.as_mut_ptr().cast::()); + let mut control = 0_u16; + let mut revision = 0_u32; + // SAFETY: descriptor points at a valid, self-relative security descriptor buffer. + unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) }.map_err( + |error| AclError::Win32 { + operation: "GetSecurityDescriptorControl", + code: error.code().0 as u32, + }, + )?; + + let mut result = AccessControlList { + // A raw read always reflects the actual, current state of the sections requested; report + // `purge: true` (the default) for consistency, since there is no partial/merge request to + // reflect here. + purge: Some(true), + ..Default::default() + }; + + if want_discretionary { + let (entries, protected) = read_acl_section(descriptor, control, false)?; + result.discretionary = Some(entries); + result.discretionary_protected = Some(protected); + } + if want_system { + let (entries, protected) = read_acl_section(descriptor, control, true)?; + result.system = Some(entries); + result.system_protected = Some(protected); + } + + Ok(result) +} + +fn read_acl_section( + descriptor: PSECURITY_DESCRIPTOR, + control: u16, + sacl: bool, +) -> Result<(Vec>, bool), AclError> { + use windows::core::BOOL; + let mut present = BOOL::default(); + let mut defaulted = BOOL::default(); + let mut acl_ptr = core::ptr::null_mut::(); + + // SAFETY: descriptor is a valid security descriptor; the output pointers are writable + // locals. + if sacl { + unsafe { GetSecurityDescriptorSacl(descriptor, &mut present, &mut acl_ptr, &mut defaulted) } + } else { + unsafe { GetSecurityDescriptorDacl(descriptor, &mut present, &mut acl_ptr, &mut defaulted) } + } + .map_err(|error| AclError::Win32 { + operation: if sacl { + "GetSecurityDescriptorSacl" + } else { + "GetSecurityDescriptorDacl" + }, + code: error.code().0 as u32, + })?; + + let entries = if present.as_bool() && !acl_ptr.is_null() { + parse_acl::

(acl_ptr)? + } else { + Vec::new() + }; + + let protected_bit = if sacl { + SE_SACL_PROTECTED.0 + } else { + SE_DACL_PROTECTED.0 + }; + let protected = control & protected_bit != 0; + + Ok((entries, protected)) +} + +/// Builds a self-relative security descriptor buffer for the requested sections of `acl`, along +/// with the `SECURITY_INFORMATION` bits identifying which sections (and protection state) to +/// write. Sections left as `None` in `acl` are omitted entirely so that callers can update the +/// DACL and/or SACL independently. +/// +/// # Errors +/// +/// Returns [`AclError`] when a principal cannot be resolved or a lower-level Win32 API call +/// fails. +pub fn build_self_relative_security_descriptor( + acl: &AccessControlList

, +) -> Result<(Vec, OBJECT_SECURITY_INFORMATION), AclError> { + let mut descriptor = SECURITY_DESCRIPTOR::default(); + let descriptor_ptr = PSECURITY_DESCRIPTOR((&raw mut descriptor).cast::()); + // SAFETY: descriptor is a local, writable SECURITY_DESCRIPTOR. + unsafe { InitializeSecurityDescriptor(descriptor_ptr, SECURITY_DESCRIPTOR_REVISION) }.map_err( + |error| AclError::Win32 { + operation: "InitializeSecurityDescriptor", + code: error.code().0 as u32, + }, + )?; + + let mut information = OBJECT_SECURITY_INFORMATION(0); + // Keep the built ACL buffers alive until the descriptor has been converted to self-relative + // form, since the absolute descriptor holds raw pointers into them. + let mut dacl_buffer = None; + let mut sacl_buffer = None; + + if let Some(entries) = &acl.discretionary { + let mut buffer = AclBuffer::build(entries.as_slice())?; + // SAFETY: descriptor_ptr is the descriptor initialized above, and buffer.as_ptr() is + // kept alive for as long as descriptor_ptr is used (stored in dacl_buffer below). + unsafe { + SetSecurityDescriptorDacl( + descriptor_ptr, + true, + Some(buffer.as_ptr().cast_const()), + false, + ) + } + .map_err(|error| AclError::Win32 { + operation: "SetSecurityDescriptorDacl", + code: error.code().0 as u32, + })?; + information |= DACL_SECURITY_INFORMATION; + information |= if acl.discretionary_protected.unwrap_or(false) { + PROTECTED_DACL_SECURITY_INFORMATION + } else { + UNPROTECTED_DACL_SECURITY_INFORMATION + }; + dacl_buffer = Some(buffer); + } + + if let Some(entries) = &acl.system { + let mut buffer = AclBuffer::build(entries.as_slice())?; + // SAFETY: see above. + unsafe { + SetSecurityDescriptorSacl( + descriptor_ptr, + true, + Some(buffer.as_ptr().cast_const()), + false, + ) + } + .map_err(|error| AclError::Win32 { + operation: "SetSecurityDescriptorSacl", + code: error.code().0 as u32, + })?; + information |= SACL_SECURITY_INFORMATION; + information |= if acl.system_protected.unwrap_or(false) { + PROTECTED_SACL_SECURITY_INFORMATION + } else { + UNPROTECTED_SACL_SECURITY_INFORMATION + }; + sacl_buffer = Some(buffer); + } + + // Also stamp the protection state directly into the descriptor's own control bits. The + // `information` bits above are the primary mechanism (honored by, for example, + // `RegSetKeySecurity`), but some legacy, path-based APIs (`SetFileSecurityW`) are documented + // as obsolete and, in practice, silently ignore the `PROTECTED_*`/`UNPROTECTED_*` bits in + // their `SecurityInformation` parameter while still persisting whatever the descriptor's own + // control word says; setting both makes the protection state apply consistently regardless + // of which Set*Security API a caller ultimately uses. + let mut control_mask = SECURITY_DESCRIPTOR_CONTROL(0); + let mut control_bits = SECURITY_DESCRIPTOR_CONTROL(0); + if acl.discretionary.is_some() { + control_mask |= SE_DACL_PROTECTED; + if acl.discretionary_protected.unwrap_or(false) { + control_bits |= SE_DACL_PROTECTED; + } + } + if acl.system.is_some() { + control_mask |= SE_SACL_PROTECTED; + if acl.system_protected.unwrap_or(false) { + control_bits |= SE_SACL_PROTECTED; + } + } + if control_mask.0 != 0 { + // SAFETY: descriptor_ptr is the descriptor initialized above. + unsafe { SetSecurityDescriptorControl(descriptor_ptr, control_mask, control_bits) } + .map_err(|error| AclError::Win32 { + operation: "SetSecurityDescriptorControl", + code: error.code().0 as u32, + })?; + } + + let self_relative = to_self_relative(descriptor_ptr); + drop(dacl_buffer); + drop(sacl_buffer); + Ok((self_relative?, information)) +} + +fn to_self_relative(descriptor: PSECURITY_DESCRIPTOR) -> Result, AclError> { + let mut size = 0_u32; + // SAFETY: descriptor is a valid, initialized (absolute-format) security descriptor. The + // first call intentionally supplies no buffer to obtain the required size, and is expected + // to fail with ERROR_INSUFFICIENT_BUFFER; the error is inspected only if size was not + // populated. + let first = + unsafe { MakeSelfRelativeSD(descriptor, Some(PSECURITY_DESCRIPTOR::default()), &mut size) }; + if size == 0 { + let code = first.err().map_or(0, |error| error.code().0 as u32); + return Err(AclError::Win32 { + operation: "MakeSelfRelativeSD", + code, + }); + } + + let mut buffer = vec![0_u8; size as usize]; + let target = PSECURITY_DESCRIPTOR(buffer.as_mut_ptr().cast::()); + // SAFETY: buffer is sized according to the size returned above. + unsafe { MakeSelfRelativeSD(descriptor, Some(target), &mut size) }.map_err(|error| { + AclError::Win32 { + operation: "MakeSelfRelativeSD", + code: error.code().0 as u32, + } + })?; + Ok(buffer) +} + +/// Returns a copy of `acl` with every entry's `principal` normalized to its resolved SID string, +/// so that a caller-supplied access control list (which may name principals by SID or by +/// account/group name) can be compared directly, field-for-field, against the output of +/// [`read_access_control_list`] (which always reports resolved SID strings), and so that +/// entries which name the same principal differently are recognized as duplicates when merging. +/// +/// # Errors +/// +/// Returns [`AclError`] when a principal cannot be resolved to a SID. +pub fn normalize(acl: &AccessControlList

) -> Result, AclError> { + Ok(AccessControlList { + discretionary: acl + .discretionary + .as_deref() + .map(normalize_entries) + .transpose()?, + discretionary_protected: acl.discretionary_protected, + system: acl.system.as_deref().map(normalize_entries).transpose()?, + system_protected: acl.system_protected, + purge: acl.purge, + }) +} + +fn normalize_entries( + entries: &[AccessControlEntry

], +) -> Result>, AclError> { + entries + .iter() + .map(|entry| { + let sid = resolve_principal(&entry.principal)?; + let principal = sid_to_string(sid.as_psid())?; + Ok(AccessControlEntry { + principal, + ..entry.clone() + }) + }) + .collect() +} diff --git a/lib/lib-acl/src/win32/sid.rs b/lib/lib-acl/src/win32/sid.rs new file mode 100644 index 000000000..3ee7c1a4d --- /dev/null +++ b/lib/lib-acl/src/win32/sid.rs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resolves access control entry principals (SID strings or account/group names) to security +//! identifiers, and converts security identifiers back to their normalized string form. + +use crate::error::AclError; +use core::ffi::c_void; +use windows::Win32::Foundation::{HLOCAL, LocalFree}; +use windows::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSidToSidW}; +use windows::Win32::Security::{GetLengthSid, LookupAccountNameW, PSID, SID_NAME_USE}; +use windows::core::{HSTRING, PCWSTR, PWSTR}; + +/// Owned bytes for a security identifier (SID). Safe to pass as a [`PSID`] for the lifetime of +/// this value. +pub struct OwnedSid(Vec); + +impl OwnedSid { + /// Returns a [`PSID`] pointing at this SID's bytes. + #[must_use] + pub fn as_psid(&self) -> PSID { + PSID(self.0.as_ptr().cast_mut().cast::()) + } +} + +/// Resolves a principal string to a security identifier. Accepts either a SID string (for +/// example `S-1-5-32-544`) or an account/group name (for example `DOMAIN\User` or +/// `Administrators`), which is resolved via [`LookupAccountNameW`]. +/// +/// # Errors +/// +/// Returns [`AclError::InvalidSid`] when `principal` looks like a SID string but is not valid, +/// or [`AclError::PrincipalResolution`] when the account/group name cannot be resolved. +pub fn resolve_principal(principal: &str) -> Result { + if looks_like_sid(principal) { + return parse_sid(principal); + } + lookup_account_name(principal) +} + +/// Converts a security identifier to its normalized string form (for example +/// `S-1-5-32-544`). +/// +/// # Errors +/// +/// Returns [`AclError::Win32`] when the conversion fails, or [`AclError::InvalidSid`] when the +/// converted string is not valid UTF-16. +pub fn sid_to_string(sid: PSID) -> Result { + let mut pwstr = PWSTR::null(); + // SAFETY: sid is a valid SID pointer for the duration of this call. The returned string is + // released via LocalFree below. + unsafe { ConvertSidToStringSidW(sid, &mut pwstr) }.map_err(|error| AclError::Win32 { + operation: "ConvertSidToStringSidW", + code: error.code().0 as u32, + })?; + + // SAFETY: pwstr was populated by ConvertSidToStringSidW and is a null-terminated UTF-16 + // string allocated with LocalAlloc. + let result = + unsafe { pwstr.to_string() }.map_err(|error| AclError::InvalidSid(error.to_string())); + // SAFETY: pwstr was allocated by ConvertSidToStringSidW with LocalAlloc. + unsafe { + let _ = LocalFree(Some(HLOCAL(pwstr.0.cast()))); + } + result +} + +fn looks_like_sid(value: &str) -> bool { + value.len() > 1 && value.as_bytes()[0].eq_ignore_ascii_case(&b'S') && value.contains('-') +} + +fn parse_sid(value: &str) -> Result { + let wide = HSTRING::from(value); + let mut psid = PSID::default(); + // SAFETY: wide is a valid null-terminated string for the duration of this call. psid is + // allocated by this API with LocalAlloc and copied into an owned buffer below. + unsafe { ConvertStringSidToSidW(PCWSTR(wide.as_ptr()), &mut psid) } + .map_err(|_| AclError::InvalidSid(value.to_string()))?; + + // SAFETY: psid was populated by ConvertStringSidToSidW and is valid until freed below. + let length = unsafe { GetLengthSid(psid) } as usize; + let mut bytes = vec![0_u8; length]; + // SAFETY: psid points to at least `length` valid bytes, and bytes has the same length. + unsafe { + core::ptr::copy_nonoverlapping(psid.0.cast::(), bytes.as_mut_ptr(), length); + let _ = LocalFree(Some(HLOCAL(psid.0))); + } + Ok(OwnedSid(bytes)) +} + +fn lookup_account_name(name: &str) -> Result { + let wide = HSTRING::from(name); + let mut sid_size = 0_u32; + let mut domain_size = 0_u32; + let mut use_: SID_NAME_USE = SID_NAME_USE(0); + + // SAFETY: This first call intentionally supplies no SID/domain buffer to obtain the + // required sizes. + let _ = unsafe { + LookupAccountNameW( + PCWSTR::null(), + PCWSTR(wide.as_ptr()), + None, + &mut sid_size, + Some(PWSTR::null()), + &mut domain_size, + &mut use_, + ) + }; + if sid_size == 0 { + return Err(AclError::PrincipalResolution { + principal: name.to_string(), + reason: "the account or group name could not be found".to_string(), + }); + } + + let mut sid_buffer = vec![0_u8; sid_size as usize]; + let mut domain_buffer = vec![0_u16; domain_size as usize]; + let psid = PSID(sid_buffer.as_mut_ptr().cast::()); + // SAFETY: sid_buffer and domain_buffer are sized according to the sizes returned above and + // are writable for the duration of this call. + unsafe { + LookupAccountNameW( + PCWSTR::null(), + PCWSTR(wide.as_ptr()), + Some(psid), + &mut sid_size, + Some(PWSTR(domain_buffer.as_mut_ptr())), + &mut domain_size, + &mut use_, + ) + } + .map_err(|error| AclError::PrincipalResolution { + principal: name.to_string(), + reason: error.to_string(), + })?; + + Ok(OwnedSid(sid_buffer)) +} diff --git a/resources/file_dsc/Cargo.toml b/resources/file_dsc/Cargo.toml index 48c04998d..810f2d91f 100644 --- a/resources/file_dsc/Cargo.toml +++ b/resources/file_dsc/Cargo.toml @@ -14,6 +14,7 @@ load-path = "locales" [dependencies] base16ct = { workspace = true } +lib-acl = { workspace = true } rust-i18n = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/resources/file_dsc/file_dsc.dsc.manifests.json b/resources/file_dsc/file_dsc.dsc.manifests.json index f70788ed4..a77d41cc2 100644 --- a/resources/file_dsc/file_dsc.dsc.manifests.json +++ b/resources/file_dsc/file_dsc.dsc.manifests.json @@ -101,8 +101,8 @@ }, { "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", - "type": "Microsoft.Filesystem/ACL", - "description": "Manage the discretionary access control list of a local file or folder.", + "type": "Microsoft.Filesystem/AccessControlList", + "description": "Manage the discretionary and/or system access control list of a local file or folder.", "tags": [ "file", "folder", @@ -149,17 +149,16 @@ "0": "Success", "1": "Invalid arguments", "2": "Invalid input", - "3": "Filesystem ACL operation error" + "3": "Filesystem access control list operation error" }, "schema": { "embedded": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Filesystem ACL", - "description": "Manage the discretionary access control list of a local file or folder.", + "title": "Filesystem access control list", + "description": "Manage the discretionary and/or system access control list of a local file or folder.", "type": "object", "required": [ - "path", - "SDDL" + "path" ], "properties": { "path": { @@ -169,11 +168,8 @@ "minLength": 1, "x-dsc-key": true }, - "SDDL": { - "type": "string", - "title": "Security descriptor definition language", - "description": "The complete discretionary access control list in SDDL form. Setting this property replaces the existing DACL, including its protection state.", - "minLength": 1 + "accessControlList": { + "$ref": "#/$defs/accessControlList" }, "_inDesiredState": { "$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/inDesiredState.json" @@ -181,6 +177,132 @@ }, "additionalProperties": false, "$defs": { + "accessControlList": { + "type": "object", + "title": "Access control list", + "description": "The discretionary (DACL) and/or system (SACL) access control list for the file or folder. Omitting a section leaves it unmanaged; at least one section must be present.", + "properties": { + "discretionary": { + "type": "array", + "title": "Discretionary access control entries", + "description": "The discretionary access control list (DACL) entries. Omit to leave the DACL unmanaged.", + "items": { + "$ref": "#/$defs/accessControlEntry" + } + }, + "discretionaryProtected": { + "type": "boolean", + "title": "Discretionary access control list is protected", + "description": "Whether the DACL is protected from inheriting entries from its parent. Omit to leave the current protection state unchanged when 'discretionary' is not provided, or to request an unprotected (inheriting) DACL when it is." + }, + "system": { + "type": "array", + "title": "System access control entries", + "description": "The system access control list (SACL) entries. Omit to leave the SACL unmanaged. Reading or writing this section requires the SeSecurityPrivilege privilege.", + "items": { + "$ref": "#/$defs/accessControlEntry" + } + }, + "systemProtected": { + "type": "boolean", + "title": "System access control list is protected", + "description": "Whether the SACL is protected from inheriting entries from its parent. Omit to leave the current protection state unchanged when 'system' is not provided, or to request an unprotected (inheriting) SACL when it is." + }, + "_purge": { + "type": "boolean", + "title": "Purge supplied sections", + "description": "Whether to replace (purge) each supplied section outright, or merge its entries into the section's current managed entries. Defaults to true (replace) when omitted. When false, the entries supplied for a section are merged into that section's existing entries: semantic duplicates (same normalized principal, ACE type, effective access mask, and inheritance/audit flags) are not duplicated, and the resulting discretionary section is canonicalized with every 'deny' entry before every 'allow' entry. A section that is omitted from the request remains unmanaged regardless of this setting.", + "default": true + } + }, + "additionalProperties": false + }, + "accessControlEntry": { + "type": "object", + "title": "Access control entry", + "description": "A single access control entry (ACE).", + "required": [ + "principal", + "type", + "accessMask" + ], + "properties": { + "principal": { + "type": "string", + "title": "Principal", + "description": "The security principal the entry applies to. Accepts either a SID string (for example 'S-1-5-32-544') or an account or group name (for example 'DOMAIN\\User' or 'Administrators'). Names are resolved to a security identifier; output always reports the normalized SID string.", + "minLength": 1 + }, + "type": { + "type": "string", + "title": "ACE type", + "description": "Whether the entry allows, denies, or audits access. 'allow' and 'deny' are valid only in the discretionary list; 'audit' is valid only in the system list.", + "enum": [ + "allow", + "deny", + "audit" + ] + }, + "accessMask": { + "type": "array", + "title": "Access mask", + "description": "The permissions granted, denied, or audited by this entry.", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "readData", + "writeData", + "appendData", + "readExtendedAttributes", + "writeExtendedAttributes", + "executeFile", + "deleteChild", + "readAttributes", + "writeAttributes", + "delete", + "readPermissions", + "changePermissions", + "takeOwnership", + "synchronize", + "read", + "write", + "readAndExecute", + "modify", + "fullControl" + ] + } + }, + "flags": { + "type": "array", + "title": "ACE flags", + "description": "Inheritance flags for the entry. Defaults to an empty list (no inheritance).", + "items": { + "type": "string", + "enum": [ + "containerInherit", + "objectInherit", + "noPropagateInherit", + "inheritOnly", + "inherited" + ] + } + }, + "auditFlags": { + "type": "array", + "title": "Audit flags", + "description": "The audit conditions for the entry. Required (and non-empty) when 'type' is 'audit'; must be empty otherwise.", + "items": { + "type": "string", + "enum": [ + "success", + "failure" + ] + } + } + }, + "additionalProperties": false + }, "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/inDesiredState.json": { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/inDesiredState.json", diff --git a/resources/file_dsc/locales/en-us.toml b/resources/file_dsc/locales/en-us.toml index 48cdf1849..ace05ed2e 100644 --- a/resources/file_dsc/locales/en-us.toml +++ b/resources/file_dsc/locales/en-us.toml @@ -27,13 +27,11 @@ sha512Mismatch = "The sha512 value does not match the content property" [acl] missingOperation = "Missing ACL operation. Usage: file_dsc acl --input " unknownOperation = "Unknown ACL operation: '%{operation}'. Expected: get, set, or test" -windowsOnly = "The filesystem ACL resource is only supported on Windows" -emptySddl = "The SDDL property must not be empty" -unsupportedSddlComponent = "The SDDL property must contain only a discretionary ACL (D:) component" +windowsOnly = "The filesystem access control list resource is only supported on Windows" +missingAccessControlList = "The accessControlList property is required" +invalidAccessControlList = "The accessControlList property must specify a discretionary and/or system section" pathNotFound = "The path '%{path}' does not exist" pathCheckError = "Failed to inspect the path '%{path}': %{error}" -invalidSddl = "The SDDL property is invalid: %{error}" -getError = "Failed to read the ACL for '%{path}': %{error}" -setError = "Failed to set the ACL for '%{path}': %{error}" -convertError = "Failed to convert the security descriptor to SDDL: %{error}" +getError = "Failed to read the access control list for '%{path}': %{error}" +setError = "Failed to set the access control list for '%{path}': %{error}" emptyDescriptor = "The security descriptor was empty" diff --git a/resources/file_dsc/src/acl.rs b/resources/file_dsc/src/acl.rs index e458cd61a..da9223858 100644 --- a/resources/file_dsc/src/acl.rs +++ b/resources/file_dsc/src/acl.rs @@ -1,78 +1,72 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::types::FilesystemAcl; -use rust_i18n::t; -use std::ptr; -use windows::Win32::Foundation::{HLOCAL, LocalFree}; -use windows::Win32::Security::Authorization::{ - ConvertSecurityDescriptorToStringSecurityDescriptorW, - ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, +use crate::types::{FileAccessControlList, FileAccessRight}; +use lib_acl::win32::{ + build_self_relative_security_descriptor, enable_security_privilege, normalize, + read_access_control_list, read_security_information, }; +use lib_acl::{AccessControlList, AclError, is_satisfied, merge_access_control_list}; +use rust_i18n::t; use windows::Win32::Security::{ - DACL_SECURITY_INFORMATION, GetFileSecurityW, GetSecurityDescriptorControl, - OBJECT_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, - SE_DACL_PROTECTED, SetFileSecurityW, UNPROTECTED_DACL_SECURITY_INFORMATION, + GetFileSecurityW, OBJECT_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SetFileSecurityW, }; -use windows::core::{PCWSTR, PWSTR}; +use windows::core::PCWSTR; -pub fn get(input: &FilesystemAcl) -> Result { - validate_input(input)?; - Ok(FilesystemAcl { +type FileAcl = AccessControlList; + +pub fn get(input: &FileAccessControlList) -> Result { + let requested = validate_input(input)?; + let actual = read_acl(&input.path, requested)?; + Ok(FileAccessControlList { path: input.path.clone(), - sddl: read_sddl(&input.path)?, + access_control_list: Some(actual), in_desired_state: None, }) } -pub fn set(input: &FilesystemAcl) -> Result { - validate_input(input)?; - let security_descriptor = SecurityDescriptor::from_sddl(&input.sddl)?; - let path = to_wide(&input.path); - let security_information = security_descriptor.dacl_security_information()?; - - unsafe { - SetFileSecurityW( - PCWSTR(path.as_ptr()), - security_information, - security_descriptor.as_ptr(), - ) +pub fn set(input: &FileAccessControlList) -> Result { + let requested = validate_input(input)?; + let normalized = normalize(requested).map_err(acl_error)?; + if normalized.system.is_some() { + enable_security_privilege().map_err(acl_error)?; } - .ok() - .map_err(|error| { - t!( - "acl.setError", - path = input.path.as_str(), - error = error.to_string() - ) - .to_string() - })?; + + // The Win32 ACL-writing APIs always replace a section outright, so a merge (`_purge: false`) + // is implemented by first computing the full, merged section(s) to write. + let to_write = if normalized.purge() { + normalized + } else { + let current = read_acl(&input.path, &normalized)?; + merge_access_control_list(¤t, &normalized) + }; + + let (mut descriptor_bytes, security_information) = + build_self_relative_security_descriptor(&to_write).map_err(acl_error)?; + write_acl(&input.path, &mut descriptor_bytes, security_information)?; get(input) } -pub fn test(input: &FilesystemAcl) -> Result { - validate_input(input)?; - let desired = SecurityDescriptor::from_sddl(&input.sddl)?.to_sddl()?; - let mut actual = get(input)?; - actual.in_desired_state = Some(actual.sddl.eq_ignore_ascii_case(&desired)); - Ok(actual) +pub fn test(input: &FileAccessControlList) -> Result { + let requested = validate_input(input)?; + let desired = normalize(requested).map_err(acl_error)?; + let actual = read_acl(&input.path, &desired)?; + let effective_desired = merge_access_control_list(&actual, &desired); + + let in_desired_state = is_satisfied(&effective_desired, &actual); + + Ok(FileAccessControlList { + path: input.path.clone(), + access_control_list: Some(actual), + in_desired_state: Some(in_desired_state), + }) } -fn validate_input(input: &FilesystemAcl) -> Result<(), String> { +fn validate_input(input: &FileAccessControlList) -> Result<&FileAcl, String> { if input.path.is_empty() { return Err(t!("input.emptyPath").to_string()); } - if input.sddl.is_empty() { - return Err(t!("acl.emptySddl").to_string()); - } - if !has_top_level_section(&input.sddl, b"D:") - || [b"O:", b"G:", b"S:"] - .iter() - .any(|section| has_top_level_section(&input.sddl, section)) - { - return Err(t!("acl.unsupportedSddlComponent").to_string()); - } match std::path::Path::new(&input.path).try_exists() { Ok(true) => {} Ok(false) => { @@ -87,152 +81,91 @@ fn validate_input(input: &FilesystemAcl) -> Result<(), String> { .to_string()); } } - Ok(()) -} -fn has_top_level_section(sddl: &str, section: &[u8; 2]) -> bool { - let bytes = sddl.as_bytes(); - let mut depth = 0_u32; - for index in 0..bytes.len().saturating_sub(1) { - match bytes[index] { - b'(' => depth += 1, - b')' => depth = depth.saturating_sub(1), - _ if depth == 0 - && bytes[index].eq_ignore_ascii_case(§ion[0]) - && bytes[index + 1] == section[1] => - { - return true; - } - _ => {} - } + let Some(requested) = input.access_control_list.as_ref() else { + return Err(t!("acl.missingAccessControlList").to_string()); + }; + if requested.discretionary.is_none() && requested.system.is_none() { + return Err(t!("acl.invalidAccessControlList").to_string()); } - false + requested.validate().map_err(acl_error)?; + Ok(requested) } -fn read_sddl(path: &str) -> Result { +fn read_acl(path: &str, requested: &FileAcl) -> Result { + let want_discretionary = requested.discretionary.is_some(); + let want_system = requested.system.is_some(); + if want_system { + enable_security_privilege().map_err(acl_error)?; + } + let path_wide = to_wide(path); - let mut required_length = 0; + let security_information = read_security_information(want_discretionary, want_system); + + let mut required_length = 0_u32; + // SAFETY: the first call intentionally supplies no buffer to obtain the required size. let first_result = unsafe { GetFileSecurityW( PCWSTR(path_wide.as_ptr()), - DACL_SECURITY_INFORMATION.0, + security_information.0, None, 0, &mut required_length, ) }; if required_length == 0 { - return Err(t!( - "acl.getError", - path = path, - error = first_result - .ok() - .err() - .map_or_else(|| t!("acl.emptyDescriptor").to_string(), |error| error.to_string()) - ) - .to_string()); + let error = first_result.ok().err().map_or_else( + || t!("acl.emptyDescriptor").to_string(), + |error| error.to_string(), + ); + return Err(t!("acl.getError", path = path, error = error).to_string()); } let mut buffer = vec![0_u8; required_length as usize]; + // SAFETY: buffer has the size requested by GetFileSecurityW above. unsafe { GetFileSecurityW( PCWSTR(path_wide.as_ptr()), - DACL_SECURITY_INFORMATION.0, + security_information.0, Some(PSECURITY_DESCRIPTOR(buffer.as_mut_ptr().cast())), required_length, &mut required_length, ) } .ok() - .map_err(|error| { - t!("acl.getError", path = path, error = error.to_string()).to_string() - })?; - - SecurityDescriptor::borrowed(buffer.as_mut_ptr().cast()).to_sddl() -} + .map_err(|error| t!("acl.getError", path = path, error = error.to_string()).to_string())?; -fn to_wide(value: &str) -> Vec { - value.encode_utf16().chain(std::iter::once(0)).collect() + read_access_control_list(&mut buffer, want_discretionary, want_system).map_err(acl_error) } -struct SecurityDescriptor { - pointer: PSECURITY_DESCRIPTOR, - owned: bool, +/// Writes `security_information`'s sections (DACL and/or SACL) to `path`, including their +/// protection state. +/// +/// `SetFileSecurityW` is documented as obsolete and, in practice, silently ignores the +/// `PROTECTED_DACL_SECURITY_INFORMATION` / `UNPROTECTED_DACL_SECURITY_INFORMATION` (and SACL +/// equivalent) bits in its `SecurityInformation` parameter. However, it does persist whatever +/// the security descriptor's own control word says, and +/// [`build_self_relative_security_descriptor`] stamps the requested protection state directly +/// into that control word for exactly this reason, so a single call here is sufficient; unlike +/// the newer `SetNamedSecurityInfoW`, it also applies the DACL/SACL literally, without merging +/// in inherited entries from the parent container. +fn write_acl( + path: &str, + descriptor_bytes: &mut [u8], + security_information: OBJECT_SECURITY_INFORMATION, +) -> Result<(), String> { + let path_wide = to_wide(path); + let descriptor = PSECURITY_DESCRIPTOR(descriptor_bytes.as_mut_ptr().cast()); + // SAFETY: descriptor_bytes remains alive for the duration of this call. + unsafe { SetFileSecurityW(PCWSTR(path_wide.as_ptr()), security_information, descriptor) } + .ok() + .map_err(|error| t!("acl.setError", path = path, error = error.to_string()).to_string()) } -impl SecurityDescriptor { - fn from_sddl(sddl: &str) -> Result { - let sddl_wide = to_wide(sddl); - let mut pointer = PSECURITY_DESCRIPTOR(ptr::null_mut()); - unsafe { - ConvertStringSecurityDescriptorToSecurityDescriptorW( - PCWSTR(sddl_wide.as_ptr()), - SDDL_REVISION_1, - &mut pointer, - None, - ) - } - .map_err(|error| t!("acl.invalidSddl", error = error.to_string()).to_string())?; - - Ok(Self { - pointer, - owned: true, - }) - } - - fn borrowed(pointer: *mut core::ffi::c_void) -> Self { - Self { - pointer: PSECURITY_DESCRIPTOR(pointer), - owned: false, - } - } - - fn as_ptr(&self) -> PSECURITY_DESCRIPTOR { - self.pointer - } - - fn dacl_security_information(&self) -> Result { - let mut control = 0_u16; - let mut revision = 0_u32; - unsafe { GetSecurityDescriptorControl(self.pointer, &mut control, &mut revision) } - .map_err(|error| t!("acl.invalidSddl", error = error.to_string()).to_string())?; - - let protection = if control & SE_DACL_PROTECTED.0 != 0 { - PROTECTED_DACL_SECURITY_INFORMATION - } else { - UNPROTECTED_DACL_SECURITY_INFORMATION - }; - Ok(DACL_SECURITY_INFORMATION | protection) - } - - fn to_sddl(&self) -> Result { - let mut output = PWSTR::null(); - unsafe { - ConvertSecurityDescriptorToStringSecurityDescriptorW( - self.pointer, - SDDL_REVISION_1, - DACL_SECURITY_INFORMATION, - &mut output, - None, - ) - } - .map_err(|error| t!("acl.convertError", error = error.to_string()).to_string())?; - - let result = unsafe { output.to_string() } - .map_err(|error| t!("acl.convertError", error = error.to_string()).to_string()); - unsafe { - let _ = LocalFree(Some(HLOCAL(output.0.cast()))); - } - result - } +fn acl_error(error: AclError) -> String { + error.to_string() } -impl Drop for SecurityDescriptor { - fn drop(&mut self) { - if self.owned { - unsafe { - let _ = LocalFree(Some(HLOCAL(self.pointer.0.cast()))); - } - } - } +fn to_wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() } diff --git a/resources/file_dsc/src/main.rs b/resources/file_dsc/src/main.rs index c12067de2..90f7c2802 100644 --- a/resources/file_dsc/src/main.rs +++ b/resources/file_dsc/src/main.rs @@ -7,7 +7,7 @@ mod file; mod types; use crate::file::{get, set, test}; -use crate::types::{FileContent, FilesystemAcl}; +use crate::types::{FileAccessControlList, FileContent}; use rust_i18n::t; use serde::Serialize; use serde_json::json; @@ -118,7 +118,7 @@ fn require_input(input: Option<&str>) -> Result { }) } -fn require_acl_input(input: Option<&str>) -> Result { +fn require_acl_input(input: Option<&str>) -> Result { let Some(input) = input else { return Err(fail(EXIT_INVALID_ARGS, &t!("main.missingInput"))); }; diff --git a/resources/file_dsc/src/types.rs b/resources/file_dsc/src/types.rs index 15dc604d7..920c009af 100644 --- a/resources/file_dsc/src/types.rs +++ b/resources/file_dsc/src/types.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use lib_acl::{AccessControlList, AccessMask}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] @@ -19,12 +20,125 @@ pub struct FileContent { pub in_desired_state: Option, } +/// The access rights understood by the Windows filesystem (files and folders). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FileAccessRight { + /// `FILE_READ_DATA` (`FILE_LIST_DIRECTORY` for a folder): read the file's data, or list a + /// folder's contents. + ReadData, + /// `FILE_WRITE_DATA` (`FILE_ADD_FILE` for a folder): write the file's data, or create a + /// file in a folder. + WriteData, + /// `FILE_APPEND_DATA` (`FILE_ADD_SUBDIRECTORY` for a folder): append data to the file, or + /// create a subdirectory in a folder. + AppendData, + /// `FILE_READ_EA`: read the file or folder's extended attributes. + ReadExtendedAttributes, + /// `FILE_WRITE_EA`: write the file or folder's extended attributes. + WriteExtendedAttributes, + /// `FILE_EXECUTE` (`FILE_TRAVERSE` for a folder): run the file, or traverse through a + /// folder without the `FILE_LIST_DIRECTORY` right. + ExecuteFile, + /// `FILE_DELETE_CHILD`: delete a folder's children, regardless of the permissions on the + /// child itself. + DeleteChild, + /// `FILE_READ_ATTRIBUTES`: read the file or folder's basic attributes. + ReadAttributes, + /// `FILE_WRITE_ATTRIBUTES`: write the file or folder's basic attributes. + WriteAttributes, + /// `DELETE`: delete the file or folder. + Delete, + /// `READ_CONTROL`: read the information in the file or folder's security descriptor, not + /// including the information in the system access control list (SACL). + ReadPermissions, + /// `WRITE_DAC`: modify the discretionary access control list (DACL) in the file or + /// folder's security descriptor. + ChangePermissions, + /// `WRITE_OWNER`: change the owner in the file or folder's security descriptor. + TakeOwnership, + /// `SYNCHRONIZE`: the right to use the file or folder handle for synchronization. + Synchronize, + /// `FILE_GENERIC_READ`: a composite of [`ReadData`](Self::ReadData), + /// [`ReadAttributes`](Self::ReadAttributes), + /// [`ReadExtendedAttributes`](Self::ReadExtendedAttributes), + /// [`ReadPermissions`](Self::ReadPermissions), and [`Synchronize`](Self::Synchronize). + Read, + /// `FILE_GENERIC_WRITE`: a composite of [`WriteData`](Self::WriteData), + /// [`AppendData`](Self::AppendData), [`WriteAttributes`](Self::WriteAttributes), + /// [`WriteExtendedAttributes`](Self::WriteExtendedAttributes), + /// [`ReadPermissions`](Self::ReadPermissions), and [`Synchronize`](Self::Synchronize). + Write, + /// The standard "Read & execute" permission: a composite of [`Read`](Self::Read) and + /// [`ExecuteFile`](Self::ExecuteFile). + ReadAndExecute, + /// The standard "Modify" permission: a composite of + /// [`ReadAndExecute`](Self::ReadAndExecute), [`Write`](Self::Write), and + /// [`Delete`](Self::Delete). + Modify, + /// `FILE_ALL_ACCESS`: a composite of every standard and file-specific right. + FullControl, +} + +impl AccessMask for FileAccessRight { + fn bits(self) -> u32 { + match self { + Self::ReadData => 0x0000_0001, + Self::WriteData => 0x0000_0002, + Self::AppendData => 0x0000_0004, + Self::ReadExtendedAttributes => 0x0000_0008, + Self::WriteExtendedAttributes => 0x0000_0010, + Self::ExecuteFile => 0x0000_0020, + Self::DeleteChild => 0x0000_0040, + Self::ReadAttributes => 0x0000_0080, + Self::WriteAttributes => 0x0000_0100, + Self::Delete => 0x0001_0000, + Self::ReadPermissions => 0x0002_0000, + Self::ChangePermissions => 0x0004_0000, + Self::TakeOwnership => 0x0008_0000, + Self::Synchronize => 0x0010_0000, + Self::Read => 0x0012_0089, + Self::Write => 0x0012_0116, + Self::ReadAndExecute => 0x0012_00A9, + Self::Modify => 0x0013_01BF, + Self::FullControl => 0x001F_01FF, + } + } + + fn all() -> &'static [Self] { + &[ + Self::ReadData, + Self::WriteData, + Self::AppendData, + Self::ReadExtendedAttributes, + Self::WriteExtendedAttributes, + Self::ExecuteFile, + Self::DeleteChild, + Self::ReadAttributes, + Self::WriteAttributes, + Self::Delete, + Self::ReadPermissions, + Self::ChangePermissions, + Self::TakeOwnership, + Self::Synchronize, + Self::Read, + Self::Write, + Self::ReadAndExecute, + Self::Modify, + Self::FullControl, + ] + } +} + +/// The discretionary and/or system access control list of a local file or folder. #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct FilesystemAcl { +pub struct FileAccessControlList { pub path: String, - #[serde(rename = "SDDL")] - pub sddl: String, + /// The discretionary and/or system access control list. Omitting a section leaves it + /// unmanaged; at least one section must be present. + #[serde(rename = "accessControlList", skip_serializing_if = "Option::is_none")] + pub access_control_list: Option>, #[serde(rename = "_inDesiredState", skip_serializing_if = "Option::is_none")] pub in_desired_state: Option, } diff --git a/resources/file_dsc/tests/filesystem_acl.tests.ps1 b/resources/file_dsc/tests/filesystem_acl.tests.ps1 index 8b2e03a71..57edaf272 100644 --- a/resources/file_dsc/tests/filesystem_acl.tests.ps1 +++ b/resources/file_dsc/tests/filesystem_acl.tests.ps1 @@ -1,19 +1,38 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'Microsoft.Filesystem/ACL tests' -Skip:(!$IsWindows) { +Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + BeforeAll { - $resourceType = 'Microsoft.Filesystem/ACL' + $resourceType = 'Microsoft.Filesystem/AccessControlList' $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - $desiredSddl = "D:(D;;FR;;;AN)(A;;FA;;;$currentSid)" + # Anonymous Logon's well-known SID; also exercises resolving an account name to a SID. + $anonymousSid = 'S-1-5-7' - function ConvertTo-CanonicalSddl { + function New-DesiredAcl { param( - [Parameter(Mandatory)] - [string]$Sddl + [string]$AnonymousAccessMask = 'read', + [string]$CurrentUserAccessMask = 'fullControl', + [bool]$Protected ) - return [Security.AccessControl.RawSecurityDescriptor]::new($Sddl).GetSddlForm('Access') + $entries = @( + @{ principal = 'ANONYMOUS LOGON'; type = 'deny'; accessMask = @($AnonymousAccessMask) } + @{ principal = $currentSid; type = 'allow'; accessMask = @($CurrentUserAccessMask) } + ) + $acl = @{ discretionary = $entries } + if ($PSBoundParameters.ContainsKey('Protected')) { + $acl.discretionaryProtected = $Protected + } + return $acl } } @@ -37,7 +56,7 @@ Describe 'Microsoft.Filesystem/ACL tests' -Skip:(!$IsWindows) { New-Item -ItemType Directory -Path (Join-Path $testRoot 'folder') | Select-Object -ExpandProperty FullName } - $json = @{ path = $path; SDDL = $desiredSddl } | ConvertTo-Json -Compress + $json = @{ path = $path; accessControlList = (New-DesiredAcl) } | ConvertTo-Json -Depth 10 -Compress $setOutput = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) @@ -47,18 +66,23 @@ Describe 'Microsoft.Filesystem/ACL tests' -Skip:(!$IsWindows) { $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) $getState = ($getOutput | ConvertFrom-Json).actualState - (ConvertTo-CanonicalSddl -Sddl $setState.SDDL) | - Should -BeExactly (ConvertTo-CanonicalSddl -Sddl $desiredSddl) - (ConvertTo-CanonicalSddl -Sddl $getState.SDDL) | - Should -BeExactly (ConvertTo-CanonicalSddl -Sddl $desiredSddl) - $getState.SDDL | Should -Match '\(D;' - $getState.SDDL | Should -Match '\(A;' + foreach ($state in @($setState, $getState)) { + $discretionary = $state.accessControlList.discretionary + $denyEntry = $discretionary | Where-Object { $_.type -eq 'deny' } + $allowEntry = $discretionary | Where-Object { $_.type -eq 'allow' } + + # The resolved SID is returned even though an account name was supplied as input. + $denyEntry.principal | Should -BeExactly $anonymousSid + $denyEntry.accessMask | Should -Contain 'read' + $allowEntry.principal | Should -BeExactly $currentSid + $allowEntry.accessMask | Should -Contain 'fullControl' + } } It 'Tests whether Allow and Deny ACEs are in the desired state' { $path = New-Item -ItemType File -Path (Join-Path $testRoot 'test.txt') | Select-Object -ExpandProperty FullName - $json = @{ path = $path; SDDL = $desiredSddl } | ConvertTo-Json -Compress + $json = @{ path = $path; accessControlList = (New-DesiredAcl) } | ConvertTo-Json -Depth 10 -Compress $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) @@ -68,17 +92,16 @@ Describe 'Microsoft.Filesystem/ACL tests' -Skip:(!$IsWindows) { ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeTrue } - It 'Reports a mismatched valid DACL as not in the desired state' { + It 'Reports a mismatched DACL as not in the desired state' { $path = New-Item -ItemType File -Path (Join-Path $testRoot 'mismatch.txt') | Select-Object -ExpandProperty FullName - $json = @{ path = $path; SDDL = $desiredSddl } | ConvertTo-Json -Compress + $json = @{ path = $path; accessControlList = (New-DesiredAcl) } | ConvertTo-Json -Depth 10 -Compress $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) - $mismatchedJson = @{ - path = $path - SDDL = "D:(D;;FW;;;AN)(A;;FA;;;$currentSid)" - } | ConvertTo-Json -Compress + $mismatchedAcl = New-DesiredAcl -AnonymousAccessMask 'write' + $mismatchedJson = @{ path = $path; accessControlList = $mismatchedAcl } | + ConvertTo-Json -Depth 10 -Compress $testOutput = $mismatchedJson | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) @@ -89,37 +112,222 @@ Describe 'Microsoft.Filesystem/ACL tests' -Skip:(!$IsWindows) { It 'Applies and tests the DACL protection state' { $path = New-Item -ItemType File -Path (Join-Path $testRoot 'protected.txt') | Select-Object -ExpandProperty FullName - $protectedSddl = $desiredSddl.Replace('D:', 'D:P') - $json = @{ path = $path; SDDL = $protectedSddl } | ConvertTo-Json -Compress + $json = @{ path = $path; accessControlList = (New-DesiredAcl -Protected $true) } | + ConvertTo-Json -Depth 10 -Compress $setOutput = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) $setState = ($setOutput | ConvertFrom-Json).afterState - $setState.SDDL | Should -Match '^D:P' + $setState.accessControlList.discretionaryProtected | Should -BeTrue $testOutput = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeTrue } - It 'Rejects SDDL components that the resource does not manage' { + Context '_purge behavior' { + It 'Replaces the discretionary section outright when _purge is omitted (default true)' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'replace.txt') | + Select-Object -ExpandProperty FullName + $json = @{ path = $path; accessControlList = (New-DesiredAcl) } | + ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $replacementAcl = @{ + discretionary = @( + @{ principal = $currentSid; type = 'allow'; accessMask = @('read') } + ) + } + $replaceJson = @{ path = $path; accessControlList = $replacementAcl } | + ConvertTo-Json -Depth 10 -Compress + $setOutput = $replaceJson | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $setState = ($setOutput | ConvertFrom-Json).afterState + + # Only the replacement entry remains; the deny entry from the original ACL is gone. + @($setState.accessControlList.discretionary).Count | Should -Be 1 + $setState.accessControlList.discretionary[0].principal | Should -BeExactly $currentSid + $setState.accessControlList.discretionary[0].accessMask | Should -Contain 'read' + } + + It 'Merges new ACEs into the existing discretionary section when _purge is false' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'merge.txt') | + Select-Object -ExpandProperty FullName + $json = @{ path = $path; accessControlList = (New-DesiredAcl) } | + ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $everyoneSid = 'S-1-1-0' + $mergeAcl = @{ + discretionary = @( + @{ principal = $everyoneSid; type = 'allow'; accessMask = @('read') } + ) + _purge = $false + } + $mergeJson = @{ path = $path; accessControlList = $mergeAcl } | + ConvertTo-Json -Depth 10 -Compress + $setOutput = $mergeJson | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $setState = ($setOutput | ConvertFrom-Json).afterState + + $principals = $setState.accessControlList.discretionary.principal + $principals | Should -Contain $anonymousSid + $principals | Should -Contain $currentSid + $principals | Should -Contain $everyoneSid + @($setState.accessControlList.discretionary).Count | Should -Be 3 + } + + It 'Does not duplicate a semantically equal ACE when merging' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'dedupe.txt') | + Select-Object -ExpandProperty FullName + $json = @{ path = $path; accessControlList = (New-DesiredAcl) } | + ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + # Same principal, type, and access mask as the existing allow entry: a semantic + # duplicate that merging must not add a second copy of. + $duplicateAcl = @{ + discretionary = @( + @{ principal = $currentSid; type = 'allow'; accessMask = @('fullControl') } + ) + _purge = $false + } + $duplicateJson = @{ path = $path; accessControlList = $duplicateAcl } | + ConvertTo-Json -Depth 10 -Compress + $setOutput = $duplicateJson | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $setState = ($setOutput | ConvertFrom-Json).afterState + + @($setState.accessControlList.discretionary).Count | Should -Be 2 + } + + It 'Orders Deny ACEs before Allow ACEs after merging' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'order.txt') | + Select-Object -ExpandProperty FullName + # Start with only an Allow entry; no Deny entry exists yet. + $initialAcl = @{ + discretionary = @( + @{ principal = $currentSid; type = 'allow'; accessMask = @('fullControl') } + ) + } + $json = @{ path = $path; accessControlList = $initialAcl } | ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $mergeAcl = @{ + discretionary = @( + @{ principal = 'ANONYMOUS LOGON'; type = 'deny'; accessMask = @('read') } + ) + _purge = $false + } + $mergeJson = @{ path = $path; accessControlList = $mergeAcl } | + ConvertTo-Json -Depth 10 -Compress + $setOutput = $mergeJson | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $setState = ($setOutput | ConvertFrom-Json).afterState + + # The Deny entry added by the merge must be ordered before the pre-existing Allow entry. + @($setState.accessControlList.discretionary)[0].type | Should -Be 'deny' + } + + It 'Tests true with _purge false when extra existing ACEs are present' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'subset.txt') | + Select-Object -ExpandProperty FullName + $json = @{ path = $path; accessControlList = (New-DesiredAcl) } | + ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $partialAcl = @{ + discretionary = @( + @{ principal = $currentSid; type = 'allow'; accessMask = @('fullControl') } + ) + _purge = $false + } + $testJson = @{ path = $path; accessControlList = $partialAcl } | + ConvertTo-Json -Depth 10 -Compress + $testOutput = $testJson | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeTrue + } + + It 'Tests false with _purge false when a requested ACE is missing' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'missing.txt') | + Select-Object -ExpandProperty FullName + $json = @{ path = $path; accessControlList = (New-DesiredAcl) } | + ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $missingAcl = @{ + discretionary = @( + @{ principal = 'S-1-1-0'; type = 'allow'; accessMask = @('read') } + ) + _purge = $false + } + $testJson = @{ path = $path; accessControlList = $missingAcl } | + ConvertTo-Json -Depth 10 -Compress + $testOutput = $testJson | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeFalse + } + } + + It 'Leaves the system access control list unread when it is not requested' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'partial.txt') | + Select-Object -ExpandProperty FullName + $json = @{ path = $path; accessControlList = (New-DesiredAcl) } | ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $getOutput = $json | dsc resource get -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $getState = ($getOutput | ConvertFrom-Json).actualState + + # Only the discretionary section was requested, so the system section must not be + # reported even though the file has one by default. + $getState.accessControlList.PSObject.Properties.Name | Should -Not -Contain 'system' + } + + It 'Rejects an accessControlList with no discretionary or system section' { $path = New-Item -ItemType File -Path (Join-Path $testRoot 'invalid.txt') | Select-Object -ExpandProperty FullName - $json = @{ path = $path; SDDL = "O:BAD:(A;;FA;;;$currentSid)" } | - ConvertTo-Json -Compress + $json = @{ path = $path; accessControlList = @{} } | ConvertTo-Json -Depth 10 -Compress + + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Not -Be 0 + (Get-Content -Raw $TestDrive/error.log) | + Should -Match 'discretionary and/or system' + } + + It 'Rejects an allow entry placed in the system section' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'badtype.txt') | + Select-Object -ExpandProperty FullName + $acl = @{ + system = @( + @{ principal = $currentSid; type = 'allow'; accessMask = @('read') } + ) + } + $json = @{ path = $path; accessControlList = $acl } | ConvertTo-Json -Depth 10 -Compress $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Not -Be 0 (Get-Content -Raw $TestDrive/error.log) | - Should -Match 'must contain only a discretionary ACL' + Should -Match "system access control entries must have type 'audit'" } It 'Reports path inspection errors without claiming the path is absent' { $json = @{ - path = "invalid$([char]0)path" - SDDL = $desiredSddl - } | ConvertTo-Json -Compress + path = "invalid$([char]0)path" + accessControlList = (New-DesiredAcl) + } | ConvertTo-Json -Depth 10 -Compress $null = $json | dsc resource get -r $resourceType -f - 2>$TestDrive/error.log @@ -127,4 +335,37 @@ Describe 'Microsoft.Filesystem/ACL tests' -Skip:(!$IsWindows) { (Get-Content -Raw $TestDrive/error.log) | Should -Match 'Failed to inspect the path' (Get-Content -Raw $TestDrive/error.log) | Should -Not -Match 'does not exist' } + + Context 'System access control list (SACL)' -Skip:(!$isElevated) { + It 'Sets and gets Audit ACEs on a file' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'audit.txt') | + Select-Object -ExpandProperty FullName + $acl = @{ + system = @( + @{ + principal = $currentSid + type = 'audit' + accessMask = @('read', 'write') + auditFlags = @('success', 'failure') + } + ) + } + $json = @{ path = $path; accessControlList = $acl } | ConvertTo-Json -Depth 10 -Compress + + $setOutput = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $setState = ($setOutput | ConvertFrom-Json).afterState + + $getOutput = $json | dsc resource get -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $getState = ($getOutput | ConvertFrom-Json).actualState + + foreach ($state in @($setState, $getState)) { + $auditEntry = $state.accessControlList.system | Select-Object -First 1 + $auditEntry.principal | Should -BeExactly $currentSid + $auditEntry.auditFlags | Should -Contain 'success' + $auditEntry.auditFlags | Should -Contain 'failure' + } + } + } } diff --git a/resources/registry/tests/registry.config.get.tests.ps1 b/resources/registry/tests/registry.config.get.tests.ps1 index 83e94f1e0..bd65c8e6b 100644 --- a/resources/registry/tests/registry.config.get.tests.ps1 +++ b/resources/registry/tests/registry.config.get.tests.ps1 @@ -43,24 +43,25 @@ Describe 'Registry config get tests' { ($result.psobject.properties | Measure-Object).Count | Should -Be 3 } - It 'Can get a registry key ACL' -Skip:(!$IsWindows) { + It 'Can get a registry key access control list' -Skip:(!$IsWindows) { $json = @{ keyPath = 'HKCU\1\AclGet' - acl = 'D:' - } | ConvertTo-Json -Compress + accessControlList = @{ discretionary = @() } + } | ConvertTo-Json -Depth 10 -Compress $result = registry config get --input $json 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) $result.keyPath | Should -BeExactly 'HKCU\1\AclGet' - $result.acl | Should -Match '^D:' + $result.accessControlList.discretionary.Count | Should -BeGreaterThan 0 + $result.accessControlList.discretionary[0].principal | Should -Match '^S-1-' } - It 'Rejects an invalid ACL when the registry key does not exist' -Skip:(!$IsWindows) { + It 'Rejects an access control list request that specifies neither discretionary nor system' -Skip:(!$IsWindows) { $json = @{ keyPath = 'HKCU\1\MissingAclGet' - acl = 'invalid' - } | ConvertTo-Json -Compress + accessControlList = @{} + } | ConvertTo-Json -Depth 10 -Compress registry config get --input $json 2>$TestDrive/error.log diff --git a/resources/registry/tests/registry.config.set.tests.ps1 b/resources/registry/tests/registry.config.set.tests.ps1 index c08251ab1..3d826a066 100644 --- a/resources/registry/tests/registry.config.set.tests.ps1 +++ b/resources/registry/tests/registry.config.set.tests.ps1 @@ -100,40 +100,209 @@ Describe 'registry config set tests' { Remove-Item -Path 'HKCU:\1' -Recurse -ErrorAction Ignore } - It 'Can set and retrieve a registry key ACL' -Skip:(!$IsWindows) { + It 'Can set and retrieve a registry key access control list' -Skip:(!$IsWindows) { New-Item -Path 'HKCU:\1\AclSet' -Force | Out-Null - $registryAcl = Get-Acl -Path 'HKCU:\1\AclSet' - $registryAcl.SetAccessRuleProtection($true, $true) - $desiredAcl = $registryAcl.GetSecurityDescriptorSddlForm( - [System.Security.AccessControl.AccessControlSections]::Access) - $json = @{ + $aclRequest = @{ keyPath = 'HKCU\1\AclSet' - acl = $desiredAcl - } | ConvertTo-Json -Compress + accessControlList = @{ + discretionary = @( + @{ + principal = $env:USERNAME + type = 'allow' + accessMask = @('fullControl') + } + ) + discretionaryProtected = $true + } + } + $json = $aclRequest | ConvertTo-Json -Depth 10 -Compress $output = registry config set --input $json 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) $output | Should -BeNullOrEmpty - (Get-Acl -Path 'HKCU:\1\AclSet').AreAccessRulesProtected | Should -BeTrue - $result = registry config get --input $json 2>$TestDrive/error.log | ConvertFrom-Json + $getJson = @{ + keyPath = 'HKCU\1\AclSet' + accessControlList = @{ discretionary = @() } + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config get --input $getJson 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) - $result.acl | Should -BeExactly $desiredAcl + $result.accessControlList.discretionaryProtected | Should -BeTrue + $result.accessControlList.discretionary.Count | Should -Be 1 + $result.accessControlList.discretionary[0].type | Should -BeExactly 'allow' + $result.accessControlList.discretionary[0].accessMask | Should -Contain 'fullControl' - $registryAcl = Get-Acl -Path 'HKCU:\1\AclSet' - $registryAcl.SetAccessRuleProtection($false, $true) - $desiredAcl = $registryAcl.GetSecurityDescriptorSddlForm( - [System.Security.AccessControl.AccessControlSections]::Access) - $json = @{ - keyPath = 'HKCU\1\AclSet' - acl = $desiredAcl - } | ConvertTo-Json -Compress + $aclRequest.accessControlList.discretionaryProtected = $false + $json = $aclRequest | ConvertTo-Json -Depth 10 -Compress registry config set --input $json 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) - (Get-Acl -Path 'HKCU:\1\AclSet').AreAccessRulesProtected | Should -BeFalse + + $result = registry config get --input $getJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $result.accessControlList.discretionaryProtected | Should -BeFalse + } + + It 'Replaces the discretionary section by default when _purge is omitted' -Skip:(!$IsWindows) { + # Anonymous Logon's well-known SID. It never matches the test process's own token, so an + # Allow/Deny entry for it can't accidentally lock the test process out of the key. + $anonymousSid = 'S-1-5-7' + + New-Item -Path 'HKCU:\1\AclPurgeDefault' -Force | Out-Null + $initial = @{ + keyPath = 'HKCU\1\AclPurgeDefault' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + @{ principal = $anonymousSid; type = 'deny'; accessMask = @('read') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $initial 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # The replacement keeps the current user's own entry (so the key stays manageable) but + # omits the anonymous deny entry; with _purge omitted (default true) that entry must go. + $replace = @{ + keyPath = 'HKCU\1\AclPurgeDefault' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $replace 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $getJson = @{ + keyPath = 'HKCU\1\AclPurgeDefault' + accessControlList = @{ discretionary = @() } + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config get --input $getJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # The second set replaced the discretionary section outright, so the anonymous deny + # entry from the original ACL is gone. + $result.accessControlList.discretionary.Count | Should -Be 1 + $result.accessControlList.discretionary[0].principal | Should -BeExactly ([Security.Principal.NTAccount]::new($env:USERNAME)).Translate([Security.Principal.SecurityIdentifier]).Value + } + + It 'Merges new ACEs into the existing discretionary section when _purge is false' -Skip:(!$IsWindows) { + $anonymousSid = 'S-1-5-7' + + New-Item -Path 'HKCU:\1\AclPurgeMerge' -Force | Out-Null + $initial = @{ + keyPath = 'HKCU\1\AclPurgeMerge' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $initial 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $merge = @{ + keyPath = 'HKCU\1\AclPurgeMerge' + accessControlList = @{ + discretionary = @( + @{ principal = $anonymousSid; type = 'allow'; accessMask = @('read') } + ) + _purge = $false + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $merge 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $getJson = @{ + keyPath = 'HKCU\1\AclPurgeMerge' + accessControlList = @{ discretionary = @() } + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config get --input $getJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # Both the original entry and the newly merged entry are present. + $result.accessControlList.discretionary.principal | Should -Contain $anonymousSid + $result.accessControlList.discretionary.Count | Should -Be 2 + } + + It 'Does not duplicate a semantically equal ACE when merging' -Skip:(!$IsWindows) { + New-Item -Path 'HKCU:\1\AclPurgeDedupe' -Force | Out-Null + $initial = @{ + keyPath = 'HKCU\1\AclPurgeDedupe' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $initial 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # Same principal, type, and access mask as the existing allow entry: a semantic + # duplicate that merging must not add a second copy of. + $merge = @{ + keyPath = 'HKCU\1\AclPurgeDedupe' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + ) + _purge = $false + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $merge 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $getJson = @{ + keyPath = 'HKCU\1\AclPurgeDedupe' + accessControlList = @{ discretionary = @() } + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config get --input $getJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $result.accessControlList.discretionary.Count | Should -Be 1 + } + + It 'Orders Deny ACEs before Allow ACEs after merging' -Skip:(!$IsWindows) { + # Anonymous Logon's well-known SID. A Deny entry for it never applies to the test + # process's own token, so merging it in can't lock the test process out of the key. + $anonymousSid = 'S-1-5-7' + + New-Item -Path 'HKCU:\1\AclPurgeOrder' -Force | Out-Null + $initial = @{ + keyPath = 'HKCU\1\AclPurgeOrder' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $initial 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $merge = @{ + keyPath = 'HKCU\1\AclPurgeOrder' + accessControlList = @{ + discretionary = @( + @{ principal = $anonymousSid; type = 'deny'; accessMask = @('read') } + ) + _purge = $false + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $merge 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $getJson = @{ + keyPath = 'HKCU\1\AclPurgeOrder' + accessControlList = @{ discretionary = @() } + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config get --input $getJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # The newly merged Deny entry must be ordered before the pre-existing Allow entry. + $result.accessControlList.discretionary[0].type | Should -BeExactly 'deny' } It 'Should succeed when _exist is false and value does not exist' -Skip:(!$IsWindows) { diff --git a/resources/registry/tests/registry.config.whatif.tests.ps1 b/resources/registry/tests/registry.config.whatif.tests.ps1 index 14025fb16..3747224f8 100644 --- a/resources/registry/tests/registry.config.whatif.tests.ps1 +++ b/resources/registry/tests/registry.config.whatif.tests.ps1 @@ -72,24 +72,40 @@ Describe 'registry config whatif tests' { ($result.psobject.properties | Measure-Object).Count | Should -Be 3 } - It 'Can whatif setting an ACL without changing it' -Skip:(!$IsWindows) { + It 'Can whatif setting an access control list without changing it' -Skip:(!$IsWindows) { New-Item -Path 'HKCU:\1\AclWhatIf' -Force | Out-Null - $before = (Get-Acl -Path 'HKCU:\1\AclWhatIf').Sddl - $registryAcl = Get-Acl -Path 'HKCU:\1\AclWhatIf' - $registryAcl.SetAccessRuleProtection($true, $true) - $desiredAcl = $registryAcl.GetSecurityDescriptorSddlForm( - [System.Security.AccessControl.AccessControlSections]::Access) + $getJson = @{ + keyPath = 'HKCU\1\AclWhatIf' + accessControlList = @{ discretionary = @() } + } | ConvertTo-Json -Depth 10 -Compress + $before = (registry config get --input $getJson 2>$TestDrive/error.log | + ConvertFrom-Json).accessControlList + $json = @{ keyPath = 'HKCU\1\AclWhatIf' - acl = $desiredAcl - } | ConvertTo-Json -Compress + accessControlList = @{ + discretionary = @( + @{ + principal = $env:USERNAME + type = 'allow' + accessMask = @('fullControl') + } + ) + discretionaryProtected = $true + } + } | ConvertTo-Json -Depth 10 -Compress $result = registry config set -w --input $json 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) - $result.acl | Should -BeExactly $desiredAcl + $result.accessControlList.discretionaryProtected | Should -BeTrue $result._metadata.whatIf | Should -Contain "Would set ACL on key 'HKCU\1\AclWhatIf'" - (Get-Acl -Path 'HKCU:\1\AclWhatIf').Sddl | Should -BeExactly $before + + $after = (registry config get --input $getJson 2>$TestDrive/error.log | + ConvertFrom-Json).accessControlList + $after.discretionaryProtected | Should -Be $before.discretionaryProtected + ($after.discretionary | ConvertTo-Json -Depth 10 -Compress) | + Should -BeExactly ($before.discretionary | ConvertTo-Json -Depth 10 -Compress) } It 'Can whatif an existing deeply nested key and value' -Skip:(!$IsWindows) { diff --git a/resources/registry/tests/registrylist_get.tests.ps1 b/resources/registry/tests/registrylist_get.tests.ps1 index 7f6f8e0c3..18e88d0ae 100644 --- a/resources/registry/tests/registrylist_get.tests.ps1 +++ b/resources/registry/tests/registrylist_get.tests.ps1 @@ -48,19 +48,20 @@ Describe 'Registry list get tests' { $out.results.result[0].actualState.registryEntries[3]._exist | Should -BeFalse } - It 'Can get ACLs in a registry list' -Skip:(!$IsWindows) { + It 'Can get access control lists in a registry list' -Skip:(!$IsWindows) { $json = @{ registryEntries = @( @{ keyPath = 'HKCU\1\AclListGet' - acl = 'D:' + accessControlList = @{ discretionary = @() } } ) - } | ConvertTo-Json -Depth 5 -Compress + } | ConvertTo-Json -Depth 10 -Compress $result = registry config get --list --input $json 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) - $result.registryEntries[0].acl | Should -Match '^D:' + $result.registryEntries[0].accessControlList.discretionary.Count | Should -BeGreaterThan 0 + $result.registryEntries[0].accessControlList.discretionary[0].principal | Should -Match '^S-1-' } } diff --git a/resources/registry/tests/registrylist_set.tests.ps1 b/resources/registry/tests/registrylist_set.tests.ps1 index f42592752..270a29b3d 100644 --- a/resources/registry/tests/registrylist_set.tests.ps1 +++ b/resources/registry/tests/registrylist_set.tests.ps1 @@ -55,26 +55,42 @@ Describe 'Registry list set tests' -Skip:(!$IsWindows) { (Get-ItemProperty -Path 'HKCU:\1\2\6' -Name 'Hello4' -ErrorAction Ignore).Hello4 | Should -BeNullOrEmpty } - It 'Can set ACLs in a registry list' -Skip:(!$IsWindows) { + It 'Can set access control lists in a registry list' -Skip:(!$IsWindows) { New-Item -Path 'HKCU:\1\AclListSet' -Force | Out-Null - $registryAcl = Get-Acl -Path 'HKCU:\1\AclListSet' - $registryAcl.SetAccessRuleProtection($true, $true) - $desiredAcl = $registryAcl.GetSecurityDescriptorSddlForm( - [System.Security.AccessControl.AccessControlSections]::Access) $json = @{ registryEntries = @( @{ keyPath = 'HKCU\1\AclListSet' - acl = $desiredAcl + accessControlList = @{ + discretionary = @( + @{ + principal = $env:USERNAME + type = 'allow' + accessMask = @('fullControl') + } + ) + discretionaryProtected = $true + } } ) - } | ConvertTo-Json -Depth 5 -Compress + } | ConvertTo-Json -Depth 10 -Compress registry config set --list --input $json 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) - (Get-Acl -Path 'HKCU:\1\AclListSet').AreAccessRulesProtected | Should -BeTrue - $result = registry config get --list --input $json 2>$TestDrive/error.log | ConvertFrom-Json - $result.registryEntries[0].acl | Should -BeExactly $desiredAcl + + $getJson = @{ + registryEntries = @( + @{ + keyPath = 'HKCU\1\AclListSet' + accessControlList = @{ discretionary = @() } + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config get --list --input $getJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $result.registryEntries[0].accessControlList.discretionaryProtected | Should -BeTrue + $result.registryEntries[0].accessControlList.discretionary.Count | Should -Be 1 + $result.registryEntries[0].accessControlList.discretionary[0].accessMask | Should -Contain 'fullControl' } } diff --git a/resources/registry/tests/registrylist_whatif.tests.ps1 b/resources/registry/tests/registrylist_whatif.tests.ps1 index 6c551df36..b3261b924 100644 --- a/resources/registry/tests/registrylist_whatif.tests.ps1 +++ b/resources/registry/tests/registrylist_whatif.tests.ps1 @@ -75,29 +75,49 @@ Describe 'Registry list set what-if tests' -Skip:(!$IsWindows) { $out.results.result[0].afterState.registryEntries[3].valueName | Should -BeExactly 'Hello4' } - It 'Can whatif setting ACLs without changing them' -Skip:(!$IsWindows) { + It 'Can whatif setting access control lists without changing them' -Skip:(!$IsWindows) { New-Item -Path 'HKCU:\1\AclListWhatIf' -Force | Out-Null - $before = (Get-Acl -Path 'HKCU:\1\AclListWhatIf').Sddl - $registryAcl = Get-Acl -Path 'HKCU:\1\AclListWhatIf' - $registryAcl.SetAccessRuleProtection($true, $true) - $desiredAcl = $registryAcl.GetSecurityDescriptorSddlForm( - [System.Security.AccessControl.AccessControlSections]::Access) + $getJson = @{ + registryEntries = @( + @{ + keyPath = 'HKCU\1\AclListWhatIf' + accessControlList = @{ discretionary = @() } + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $before = (registry config get --list --input $getJson 2>$TestDrive/error.log | + ConvertFrom-Json).registryEntries[0].accessControlList + $json = @{ registryEntries = @( @{ keyPath = 'HKCU\1\AclListWhatIf' - acl = $desiredAcl + accessControlList = @{ + discretionary = @( + @{ + principal = $env:USERNAME + type = 'allow' + accessMask = @('fullControl') + } + ) + discretionaryProtected = $true + } } ) - } | ConvertTo-Json -Depth 5 -Compress + } | ConvertTo-Json -Depth 10 -Compress $result = registry config set --list -w --input $json 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) - $result.registryEntries[0].acl | Should -BeExactly $desiredAcl + $result.registryEntries[0].accessControlList.discretionaryProtected | Should -BeTrue $result.registryEntries[0]._metadata.whatIf | Should -Contain "Would set ACL on key 'HKCU\1\AclListWhatIf'" - (Get-Acl -Path 'HKCU:\1\AclListWhatIf').Sddl | Should -BeExactly $before + + $after = (registry config get --list --input $getJson 2>$TestDrive/error.log | + ConvertFrom-Json).registryEntries[0].accessControlList + $after.discretionaryProtected | Should -Be $before.discretionaryProtected + ($after.discretionary | ConvertTo-Json -Depth 10 -Compress) | + Should -BeExactly ($before.discretionary | ConvertTo-Json -Depth 10 -Compress) } } From 7f4587fe251277b6eaf64c3c10f86fa9c28d9eae Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 17 Oct 2026 11:38:15 -0700 Subject: [PATCH 2/9] Address ACL review feedback Add native registry test support, preserve complete access masks and canonical ACE ordering, harden raw Win32 boundaries, and align merge and desired-state semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Microsoft/Windows/Registry/index.md | 80 +++- lib/dsc-lib-registry/src/acl.rs | 175 ++++++++- lib/dsc-lib-registry/src/config.rs | 34 +- lib/dsc-lib-registry/src/lib.rs | 257 ++++++++++++- lib/lib-acl/src/error.rs | 22 ++ lib/lib-acl/src/model.rs | 363 +++++++++++++++--- lib/lib-acl/src/win32/acl.rs | 20 +- lib/lib-acl/src/win32/security_descriptor.rs | 126 +++++- lib/lib-acl/src/win32/sid.rs | 106 ++++- .../file_dsc/file_dsc.dsc.manifests.json | 14 +- resources/file_dsc/src/acl.rs | 19 +- resources/file_dsc/src/types.rs | 21 + .../file_dsc/tests/filesystem_acl.tests.ps1 | 73 ++++ resources/registry/locales/en-us.toml | 1 + .../registry/registry.dsc.manifests.json | 25 ++ resources/registry/src/args.rs | 7 + resources/registry/src/main.rs | 47 ++- resources/registry/src/types.rs | 4 + .../tests/registry.config.set.tests.ps1 | 107 ++++++ .../tests/registry.config.test.tests.ps1 | 151 ++++++++ .../tests/registry.config.whatif.tests.ps1 | 65 ++++ 21 files changed, 1580 insertions(+), 137 deletions(-) create mode 100644 resources/registry/tests/registry.config.test.tests.ps1 diff --git a/docs/reference/resources/Microsoft/Windows/Registry/index.md b/docs/reference/resources/Microsoft/Windows/Registry/index.md index ec02fc88d..7dcbb5daf 100644 --- a/docs/reference/resources/Microsoft/Windows/Registry/index.md +++ b/docs/reference/resources/Microsoft/Windows/Registry/index.md @@ -68,12 +68,15 @@ The resource has the following capabilities: - `get` - You can use the resource to retrieve the actual state of an instance. - `set` - You can use the resource to enforce the desired state for an instance. +- `test` - You can use the resource to determine whether an instance is in the desired state. - `whatIf` - The resource is able to report how it would change system state during a **Set** operation in what-if mode. - `delete` - You can use the resource to directly remove an instance from the system. -This resource uses the synthetic test functionality of DSC to determine whether an instance is in -the desired state. For more information about resource capabilities, see +The resource implements its own **Test** operation rather than relying on the synthetic test +functionality of DSC, so that the `accessControlList` property is compared using access control +list semantics (principal normalization, alias-aware access mask comparison, and `_purge`-aware +entry matching) instead of raw JSON equality. For more information about resource capabilities, see [DSC resource capabilities][02]. ## Examples @@ -117,6 +120,8 @@ The following list describes the properties for the resource. properties, but they aren't configurable. For more information about read-only properties, see the "Read-only resource properties" section in [DSC resource properties][08]. + - [_inDesiredState](#_indesiredstate) - Indicates whether the instance is in the desired state. + Only returned by a **Test** operation. - [_metadata](#_metadata) - Defines metadata returned by the resource. ### keyPath @@ -241,9 +246,12 @@ Type : boolean

-Defines whether the DACL is protected from inheriting entries from its parent key. Omit this -property to leave the current protection state unchanged when `discretionary` isn't specified, or -to request an unprotected (inheriting) DACL when it is specified without this property. +Defines whether the DACL is protected from inheriting entries from its parent key. Only meaningful +when `discretionary` is also specified. Omitting this property behaves according to `_purge`: when +purging (the default), the DACL is written as unprotected (inheriting), since a purge write always +stamps an explicit protection state; when merging (`_purge: false`), the current protection state +is preserved unchanged. Set this explicitly to request a particular protection state regardless of +`_purge`. #### system accessControlList @@ -273,9 +281,11 @@ Type : boolean -Defines whether the SACL is protected from inheriting entries from its parent key. Omit this -property to leave the current protection state unchanged when `system` isn't specified, or to -request an unprotected (inheriting) SACL when it is specified without this property. +Defines whether the SACL is protected from inheriting entries from its parent key. Only meaningful +when `system` is also specified. Omitting this property behaves according to `_purge`: when purging +(the default), the SACL is written as unprotected (inheriting), since a purge write always stamps an +explicit protection state; when merging (`_purge: false`), the current protection state is preserved +unchanged. Set this explicitly to request a particular protection state regardless of `_purge`. #### _purge accessControlList @@ -291,18 +301,28 @@ Default : true Defines whether to replace (purge) each supplied section outright, or merge its entries into the section's current managed entries. Defaults to `true` (replace) when omitted, which is the historical full-replacement behavior: the `discretionary` and/or `system` entries supplied in a -**Set** operation become the section's entire content. +**Set** operation become the section's entire content. When a section is replaced outright and its +corresponding `discretionaryProtected`/`systemProtected` property isn't specified, the protection +state resets to unprotected (`false`) rather than being left unchanged, since a purge write always +stamps an explicit protection state. When `false`, the entries supplied for a section are merged into that section's existing entries instead of replacing them: entries that already exist are left in place, newly supplied entries are appended, and semantic duplicates (the same normalized principal, ACE type, effective access mask, and inheritance/audit flags) aren't duplicated. After merging, the resulting `discretionary` section -is canonicalized so that every `deny` entry precedes every `allow` entry. A section that isn't -supplied in the request remains unmanaged regardless of this setting. +is canonicalized into Windows' canonical access control entry order: every explicit `deny` entry, +then every explicit `allow` entry, then every inherited entry, preserving the inherited entries' +existing relative order. In particular, an inherited `deny` entry is never moved ahead of an +explicit `allow` entry, since Windows always places every explicit entry before any inherited entry +regardless of allow/deny. A section that isn't supplied in the request remains unmanaged, and its +corresponding protection property (if any) is preserved unchanged, regardless of this setting. For a **Test** operation, `_purge: false` is satisfied when every requested entry is present (after normalization) in the actual access control list; extra entries already present in the -actual ACL don't cause a mismatch. `_purge: true` (the default) requires an exact match. +actual ACL don't cause a mismatch. `_purge: true` (the default) requires the same entries in the +same order and count as `actual`, but each entry pair is compared semantically: an aliased +permission name (for example `fullControl` versus the individual rights it implies) or a +differently ordered `flags` list that is otherwise equivalent doesn't cause a mismatch. #### Access control entry object @@ -315,9 +335,13 @@ Each item in the `discretionary` and `system` arrays is an object with the follo `allow`, `deny`, and `audit`. - **accessMask** (required) - The permissions granted, denied, or audited by this entry. Must specify at least one value. Valid values are `queryValue`, `setValue`, `createSubKey`, - `enumerateSubKeys`, `notify`, `createLink`, `wow6464Key`, `wow6432Key`, `delete`, - `readPermissions`, `changePermissions`, `takeOwnership`, `synchronize`, `read`, `write`, - `execute`, and `fullControl`. + `enumerateSubKeys`, `notify`, `createLink`, `delete`, `readPermissions`, `changePermissions`, + `takeOwnership`, `synchronize`, `read`, `write`, `execute`, `fullControl`, `genericRead`, + `genericWrite`, `genericExecute`, and `genericAll`. The `generic*` values represent the raw + `GENERIC_*` access mask bits; Windows maps them to the equivalent registry-specific rights (for + example `genericAll` to `fullControl`) and they're accepted so that an existing entry using them + can always be read back and represented, but there's normally no reason to specify them in a + request. - **flags** (optional) - Inheritance flags for the entry. Defaults to an empty array (no inheritance). Valid values are `containerInherit`, `objectInherit`, `noPropagateInherit`, `inheritOnly`, and `inherited`. @@ -464,6 +488,23 @@ IsKey: false Defines the name of the value to manage for the registry key. This property is required when specifying the `valueData` property. +### _inDesiredState + +
Expand for _inDesiredState property metadata + +```yaml +Type : [boolean, 'null'] +IsRequired : false +IsKey : false +IsReadOnly : true +IsWriteOnly : false +``` + +
+ +Indicates whether the instance is in the desired state. This property is only returned by a +**Test** operation. + ### _metadata
Expand for _metadata property metadata @@ -527,6 +568,10 @@ non validating keywords are omitted. "type": "boolean", "default": true }, + "_inDesiredState": { + "type": ["boolean", "null"], + "readOnly": true + }, "_metadata": { "type": "object", "readOnly": true, @@ -562,9 +607,10 @@ non validating keywords are omitted. "items": { "enum": [ "queryValue", "setValue", "createSubKey", "enumerateSubKeys", "notify", - "createLink", "wow6464Key", "wow6432Key", "delete", "readPermissions", + "createLink", "delete", "readPermissions", "changePermissions", "takeOwnership", "synchronize", "read", "write", - "execute", "fullControl" + "execute", "fullControl", "genericRead", "genericWrite", "genericExecute", + "genericAll" ] } }, diff --git a/lib/dsc-lib-registry/src/acl.rs b/lib/dsc-lib-registry/src/acl.rs index 77a9e8cf0..e90e24405 100644 --- a/lib/dsc-lib-registry/src/acl.rs +++ b/lib/dsc-lib-registry/src/acl.rs @@ -45,16 +45,43 @@ impl Drop for RegistryKey { } } -/// Validates that `requested` is well-formed (for example that `audit` entries are only used in -/// the system section) without touching the registry. +/// Validates that `requested` is well-formed: that it specifies at least one of `discretionary` +/// or `system` (an `accessControlList` with neither is never meaningful, since there would be +/// nothing to read, write, or compare), and that every entry is well-formed for the section it +/// belongs to (for example that `audit` entries are only used in the system section). Performs +/// no registry access. +/// +/// Called centrally by every operation ([`get`], [`set`] via [`effective`], and the registry +/// resource's `test`) before any registry read, mutation, or what-if preview, so that an invalid +/// `accessControlList: {}` is rejected consistently regardless of which operation is requested. /// /// # Errors /// /// Returns [`RegistryError::Acl`] when `requested` is not well-formed. pub fn validate(requested: &RegistryAccessControlList) -> Result<(), RegistryError> { + if requested.discretionary.is_none() && requested.system.is_none() { + return Err(RegistryError::Acl(t!("error.invalidAcl").to_string())); + } requested.validate().map_err(acl_error) } +/// Enables the `SeSecurityPrivilege` privilege when `requested` manages the system access +/// control list (SACL), since reading or writing a SACL -- or merely opening a key with the +/// `ACCESS_SYSTEM_SECURITY` right, as [`can_set`] does to check whether a key can be written to +/// -- requires it. Centralizes the check so every entry point ([`get`], [`set`], and [`can_set`], +/// including when running in what-if mode) enables the privilege before its first SACL-touching +/// Win32 call, rather than relying on each call site to remember to do so individually. +/// +/// # Errors +/// +/// Returns [`RegistryError::Acl`] when the privilege can't be enabled. +fn ensure_privilege_for(requested: &RegistryAccessControlList) -> Result<(), RegistryError> { + if requested.system.is_some() { + enable_security_privilege().map_err(acl_error)?; + } + Ok(()) +} + /// Reads the sections of `path`'s access control list that are requested (non-`None`) in /// `requested`. Reading the system access control list (SACL) requires the current process to /// hold the `SeSecurityPrivilege` privilege, which this function enables as needed. @@ -67,15 +94,11 @@ pub fn get( path: &str, requested: &RegistryAccessControlList, ) -> Result { + validate(requested)?; let want_discretionary = requested.discretionary.is_some(); let want_system = requested.system.is_some(); - if !want_discretionary && !want_system { - return Err(RegistryError::Acl(t!("error.invalidAcl").to_string())); - } - if want_system { - enable_security_privilege().map_err(acl_error)?; - } + ensure_privilege_for(requested)?; let key = open(path, read_access(requested))?; let security_information = read_security_information(want_discretionary, want_system); @@ -96,18 +119,25 @@ pub fn get( return Err(win32_error("RegGetKeySecurity", status.0)); } - read_access_control_list(&mut descriptor_bytes, want_discretionary, want_system) + // SAFETY: descriptor_bytes was just populated in-place by a successful RegGetKeySecurity + // call above, sized exactly to the `size` that same API reported; it is therefore a + // complete, well-formed self-relative security descriptor. + unsafe { read_access_control_list(&mut descriptor_bytes, want_discretionary, want_system) } .map_err(acl_error) } /// Returns whether `path` currently exists and could accept `requested` being applied via -/// [`set`]. +/// [`set`]. Opening the key for this check alone requires the `SeSecurityPrivilege` privilege +/// when `requested` manages the system access control list (SACL), since the check opens the key +/// with the `ACCESS_SYSTEM_SECURITY` right; this function enables the privilege as needed +/// (including when called to preview a what-if `set`, which never calls [`set`] itself). /// /// # Errors /// /// Returns [`RegistryError::Acl`] when a lower-level Win32 API call fails for a reason other /// than the key not existing. pub fn can_set(path: &str, requested: &RegistryAccessControlList) -> Result { + ensure_privilege_for(requested)?; match open(path, write_access(requested)) { Ok(_) => Ok(true), Err(RegistryError::RegistryKeyNotFound(_)) => Ok(false), @@ -125,10 +155,8 @@ pub fn can_set(path: &str, requested: &RegistryAccessControlList) -> Result Result<(), RegistryError> { - requested.validate().map_err(acl_error)?; - if requested.system.is_some() { - enable_security_privilege().map_err(acl_error)?; - } + validate(requested)?; + ensure_privilege_for(requested)?; let to_write = effective(path, requested)?; let (mut descriptor_bytes, security_information) = @@ -146,11 +174,12 @@ pub fn set(path: &str, requested: &RegistryAccessControlList) -> Result<(), Regi } /// Computes the effective access control list that should be written for `requested`, honoring -/// its `_purge` setting: when `true` (the default), returns `requested` (normalized) as-is, so -/// that [`set`] continues to replace each supplied section outright exactly as before. When -/// `false`, reads `path`'s current state for the section(s) `requested` manages and merges -/// `requested`'s entries into them (deduplicating semantic duplicates and ordering `deny` -/// entries first in the discretionary section). +/// its `_purge` setting: when `true` (the default), each supplied section is `requested`'s +/// section (normalized) verbatim, and no registry read is needed to compute it -- a protection +/// flag that is not explicitly supplied resolves to its effective `false` default rather than +/// being left unresolved (see [`merge_access_control_list`]). When `false`, reads `path`'s +/// current state for the section(s) `requested` manages and merges `requested`'s entries into +/// them (deduplicating semantic duplicates and canonicalizing ACE order). /// /// # Errors /// @@ -163,13 +192,35 @@ pub fn effective( ) -> Result { let normalized = normalize(requested).map_err(acl_error)?; if normalized.purge() { - return Ok(normalized); + // `merge_access_control_list`'s purge branch never consults `current`, so an empty + // placeholder avoids a registry read that isn't needed to compute the effective ACL -- + // this also lets a purge write preview succeed even when `path` doesn't exist yet. + return Ok(merge_access_control_list(&RegistryAccessControlList::default(), &normalized)); } let current = get(path, &normalized)?; Ok(merge_access_control_list(¤t, &normalized)) } +/// Returns whether `actual` (as returned by [`get`] for the section(s) `requested` manages) +/// already satisfies `requested`, honoring its `_purge` setting exactly as [`effective`] does +/// when computing what a [`set`] would write, but without performing a registry read: the merge +/// is computed directly against the already-available `actual`, which is what a purge-mode +/// `_purge: true` write would need anyway and what a merge-mode `_purge: false` write would +/// otherwise have to re-read from the registry. +/// +/// # Errors +/// +/// Returns [`RegistryError::Acl`] when a principal in `requested` cannot be resolved. +pub fn is_satisfied( + requested: &RegistryAccessControlList, + actual: &RegistryAccessControlList, +) -> Result { + let normalized = normalize(requested).map_err(acl_error)?; + let effective_desired = merge_access_control_list(actual, &normalized); + Ok(lib_acl::is_satisfied(&effective_desired, actual)) +} + fn read_access(requested: &RegistryAccessControlList) -> REG_SAM_FLAGS { let mut access = REG_SAM_FLAGS(0); if requested.discretionary.is_some() { @@ -341,4 +392,88 @@ mod tests { // The current, pre-existing entries are still present alongside the newly requested one. assert!(merged.len() > 1); } + + #[test] + fn validate_rejects_access_control_list_with_no_sections() { + let requested = RegistryAccessControlList::default(); + let error = validate(&requested).unwrap_err(); + assert!(matches!(error, RegistryError::Acl(_))); + } + + #[test] + fn effective_with_purge_true_defaults_protected_to_false_without_reading_current() { + let requested = RegistryAccessControlList { + discretionary: Some(vec![entry(AceType::Allow)]), + purge: Some(true), + ..Default::default() + }; + + // Succeeds (and resolves the omitted protection flag to its effective default) even + // though the key below doesn't exist, proving no registry read of "current" is needed + // to compute the effective ACL in purge mode. + let effective_acl = + effective(r"HKCU\DoesNotExist\AclPurgeProtectedDefault", &requested).unwrap(); + + assert_eq!(effective_acl.discretionary_protected, Some(false)); + } + + #[test] + fn is_satisfied_reports_true_when_actual_already_matches_requested() { + let actual = RegistryAccessControlList { + discretionary: Some(vec![entry(AceType::Allow)]), + discretionary_protected: Some(false), + ..Default::default() + }; + let requested = RegistryAccessControlList { + discretionary: Some(vec![entry(AceType::Allow)]), + purge: Some(true), + ..Default::default() + }; + + assert!(is_satisfied(&requested, &actual).unwrap()); + } + + #[test] + fn is_satisfied_reports_false_when_actual_differs() { + let actual = RegistryAccessControlList { + discretionary: Some(vec![entry(AceType::Allow)]), + discretionary_protected: Some(false), + ..Default::default() + }; + let mut different_entry = entry(AceType::Allow); + different_entry.principal = "S-1-5-19".to_string(); + let requested = RegistryAccessControlList { + discretionary: Some(vec![different_entry]), + purge: Some(true), + ..Default::default() + }; + + assert!(!is_satisfied(&requested, &actual).unwrap()); + } + + #[test] + fn can_set_enables_privilege_before_checking_a_missing_key_with_system_acl() { + // This behavior can only be exercised when the test process actually holds + // `SeSecurityPrivilege` (even if not yet enabled); skip gracefully otherwise, mirroring + // how other SACL-touching tests in this codebase are conditioned on elevation. + if lib_acl::win32::enable_security_privilege().is_err() { + return; + } + + // Requesting a `system` section means `can_set` must open the key with + // ACCESS_SYSTEM_SECURITY, which itself requires `SeSecurityPrivilege` to already be + // enabled; if `can_set` attempted to open the key before enabling the privilege, this + // would fail with a privilege error instead of gracefully reporting that the + // (non-existent) key can't be written to. + let mut audit_entry = entry(AceType::Audit); + audit_entry.audit_flags = vec![AuditFlag::Success]; + let requested = RegistryAccessControlList { + system: Some(vec![audit_entry]), + ..Default::default() + }; + + let can_set_result = can_set(r"HKCU\DoesNotExist\AclCanSetSystem", &requested); + + assert!(matches!(can_set_result, Ok(false)), "{can_set_result:?}"); + } } diff --git a/lib/dsc-lib-registry/src/config.rs b/lib/dsc-lib-registry/src/config.rs index 14a943b74..713c8219a 100644 --- a/lib/dsc-lib-registry/src/config.rs +++ b/lib/dsc-lib-registry/src/config.rs @@ -21,12 +21,6 @@ pub enum RegistryAccessRight { Notify, /// `KEY_CREATE_LINK`: Reserved for system use. CreateLink, - /// `KEY_WOW64_64KEY`: Indicates that an application on 64-bit Windows should operate on - /// the 64-bit registry view. - Wow6464Key, - /// `KEY_WOW64_32KEY`: Indicates that an application on 64-bit Windows should operate on - /// the 32-bit registry view. - Wow6432Key, /// `DELETE`: Required to delete the registry key. Delete, /// `READ_CONTROL`: Required to read the information in the registry key's security @@ -50,6 +44,19 @@ pub enum RegistryAccessRight { Execute, /// `KEY_ALL_ACCESS`: A composite of every standard and registry-specific right. FullControl, + /// `GENERIC_READ`: the generic read right. Windows maps this to [`Read`](Self::Read) for + /// registry key objects, but the raw bit can still appear in an existing access control + /// entry's mask, so it must be representable rather than silently discarded when read back. + GenericRead, + /// `GENERIC_WRITE`: the generic write right, mapped by Windows to [`Write`](Self::Write) + /// for registry key objects. + GenericWrite, + /// `GENERIC_EXECUTE`: the generic execute right, mapped by Windows to + /// [`Execute`](Self::Execute) for registry key objects. + GenericExecute, + /// `GENERIC_ALL`: the generic all-access right, mapped by Windows to + /// [`FullControl`](Self::FullControl) for registry key objects. + GenericAll, } impl AccessMask for RegistryAccessRight { @@ -61,8 +68,6 @@ impl AccessMask for RegistryAccessRight { Self::EnumerateSubKeys => 0x0000_0008, Self::Notify => 0x0000_0010, Self::CreateLink => 0x0000_0020, - Self::Wow6464Key => 0x0000_0100, - Self::Wow6432Key => 0x0000_0200, Self::Delete => 0x0001_0000, Self::ReadPermissions => 0x0002_0000, Self::ChangePermissions => 0x0004_0000, @@ -71,6 +76,10 @@ impl AccessMask for RegistryAccessRight { Self::Read | Self::Execute => 0x0002_0019, Self::Write => 0x0002_0006, Self::FullControl => 0x000F_003F, + Self::GenericExecute => 0x2000_0000, + Self::GenericWrite => 0x4000_0000, + Self::GenericRead => 0x8000_0000, + Self::GenericAll => 0x1000_0000, } } @@ -82,8 +91,6 @@ impl AccessMask for RegistryAccessRight { Self::EnumerateSubKeys, Self::Notify, Self::CreateLink, - Self::Wow6464Key, - Self::Wow6432Key, Self::Delete, Self::ReadPermissions, Self::ChangePermissions, @@ -93,6 +100,10 @@ impl AccessMask for RegistryAccessRight { Self::Write, Self::Execute, Self::FullControl, + Self::GenericRead, + Self::GenericWrite, + Self::GenericExecute, + Self::GenericAll, ] } } @@ -132,6 +143,9 @@ pub struct Registry { /// are performed against the offline hive instead of the live system registry. #[serde(rename = "registryFilePath", skip_serializing_if = "Option::is_none")] pub registry_file_path: Option, + /// Whether the instance is in the desired state. Only returned by a `test` operation. + #[serde(rename = "_inDesiredState", skip_serializing_if = "Option::is_none")] + pub in_desired_state: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] diff --git a/lib/dsc-lib-registry/src/lib.rs b/lib/dsc-lib-registry/src/lib.rs index 44e1a852b..5078aa99d 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -110,6 +110,7 @@ impl RegistryHelper { metadata: None, exist: None, registry_file_path: None, + in_desired_state: None, }; Ok( Self { @@ -367,16 +368,27 @@ impl RegistryHelper { }; if self.what_if { - if self.config.access_control_list.is_some() { + let mut what_if_acl = self.config.access_control_list.clone(); + if let Some(config_acl) = &self.config.access_control_list { what_if_metadata.push( t!("registry_helper.whatIfSetAcl", key_path = &self.config.key_path).to_string(), ); + // The key already exists (`reg_key` is `Some` whenever it was opened + // rather than merely simulated for creation above), so the what-if preview + // can -- and must -- compute the same effective, normalized/merged ACL that + // an actual `set` would write, instead of echoing the raw request back + // unresolved. A key that's only being simulated for creation has no current + // state to merge with, so that simulation is kept separate and still returns + // the raw (normalized-at-write-time) request. + if reg_key.is_some() { + what_if_acl = Some(acl::effective(&self.config.key_path, config_acl)?); + } } return Ok(Some(Registry { key_path: self.config.key_path.clone(), value_data: convert_reg_value(&data)?, value_name: self.config.value_name.clone(), - access_control_list: self.config.access_control_list.clone(), + access_control_list: what_if_acl, metadata: if what_if_metadata.is_empty() { None } else { Some(Metadata { what_if: Some(what_if_metadata) })}, ..Default::default() })); @@ -408,6 +420,61 @@ impl RegistryHelper { Ok(None) } + /// Test whether the registry matches the desired state. + /// + /// Compares `_exist`, `valueData` (when `valueName` is specified), and `accessControlList` + /// (when specified) against the actual state returned by [`Self::get`]. The `accessControlList` + /// comparison uses the shared access control list satisfaction semantics (principal + /// normalization, alias-aware access mask comparison, and `_purge`-aware entry matching) + /// instead of raw JSON equality, so that, for example, a differently cased or + /// differently-named-but-equivalent principal, or an access mask expressed as an alias versus + /// its decomposed rights, doesn't produce a false drift report. + /// + /// # Returns + /// + /// * `Registry` - The registry struct with `_inDesiredState` populated. + /// + /// # Errors + /// + /// * `RegistryError` - The error that occurred. + pub fn test(&self) -> Result { + if self.offline_hive.is_some() { + return self.test_offline(); + } + + // `get` performs the same accessControlList validation (for example rejecting + // `accessControlList: {}`) that `set` does, so there's no need to duplicate it here. + let actual = self.get()?; + + let desired_exist = self.config.exist.unwrap_or(true); + let actual_exist = actual.exist.unwrap_or(true); + let mut in_desired_state = desired_exist == actual_exist; + + // Value data and the access control list only matter when the instance is (and should + // be) present; when existence itself doesn't match, that alone determines compliance. + if desired_exist && actual_exist { + if self.config.value_name.is_some() { + let desired_value_data = self.config.value_data.clone().unwrap_or(RegistryValueData::None); + let actual_value_data = actual.value_data.clone().unwrap_or(RegistryValueData::None); + if desired_value_data != actual_value_data { + in_desired_state = false; + } + } + + if let Some(requested_acl) = &self.config.access_control_list { + let actual_acl = actual.access_control_list.clone().unwrap_or_default(); + if !acl::is_satisfied(requested_acl, &actual_acl)? { + in_desired_state = false; + } + } + } + + Ok(Registry { + in_desired_state: Some(in_desired_state), + ..actual + }) + } + /// Delete from registry. /// /// # Returns @@ -561,6 +628,31 @@ impl RegistryHelper { } } + /// Test whether an offline registry hive matches the desired state. Access control lists + /// are never supported for offline hives, so [`Self::get_offline`] rejects a request that + /// specifies one; otherwise, only existence and (when `valueName` is specified) value data + /// are compared. + fn test_offline(&self) -> Result { + let actual = self.get_offline()?; + + let desired_exist = self.config.exist.unwrap_or(true); + let actual_exist = actual.exist.unwrap_or(true); + let mut in_desired_state = desired_exist == actual_exist; + + if desired_exist && actual_exist && self.config.value_name.is_some() { + let desired_value_data = self.config.value_data.clone().unwrap_or(RegistryValueData::None); + let actual_value_data = actual.value_data.clone().unwrap_or(RegistryValueData::None); + if desired_value_data != actual_value_data { + in_desired_state = false; + } + } + + Ok(Registry { + in_desired_state: Some(in_desired_state), + ..actual + }) + } + fn set_offline(&self) -> Result, RegistryError> { let hive = self.offline_hive.as_ref().unwrap(); let mut what_if_metadata: Vec = Vec::new(); @@ -1002,3 +1094,164 @@ fn delete_tree() { assert_eq!(result.value_data, None); assert_eq!(result.exist, Some(false)); } + +#[test] +fn test_reports_in_desired_state_when_value_matches() { + let helper = RegistryHelper::new_from_json(r#"{"keyPath":"HKCU\\DSCTestNative\\Value","valueName":"TestValue","valueData": { "String": "Hello"} }"#).unwrap(); + helper.set().unwrap(); + + let result = helper.test().unwrap(); + + assert_eq!(result.in_desired_state, Some(true)); + + RegistryHelper::new_from_json(r#"{"keyPath":"HKCU\\DSCTestNative"}"#).unwrap().remove().unwrap(); +} + +#[test] +fn test_reports_not_in_desired_state_when_value_differs() { + let helper = RegistryHelper::new_from_json(r#"{"keyPath":"HKCU\\DSCTestNative2\\Value","valueName":"TestValue","valueData": { "String": "Hello"} }"#).unwrap(); + helper.set().unwrap(); + + let desired = RegistryHelper::new_from_json(r#"{"keyPath":"HKCU\\DSCTestNative2\\Value","valueName":"TestValue","valueData": { "String": "Goodbye"} }"#).unwrap(); + let result = desired.test().unwrap(); + + assert_eq!(result.in_desired_state, Some(false)); + + RegistryHelper::new_from_json(r#"{"keyPath":"HKCU\\DSCTestNative2"}"#).unwrap().remove().unwrap(); +} + +#[test] +fn test_reports_not_in_desired_state_when_exist_mismatches() { + let desired = RegistryHelper::new_from_json(r#"{"keyPath":"HKCU\\DSCTestNativeDoesNotExist"}"#).unwrap(); + + let result = desired.test().unwrap(); + + assert_eq!(result.in_desired_state, Some(false)); +} + +#[test] +fn test_satisfies_purge_true_acl_via_alias_equivalent_access_mask() { + // The individual rights below, combined, are bit-for-bit identical to `fullControl`; a real + // system read always reports the most comprehensive alias that fits exactly, so a + // subsequent Get/Test will see `fullControl` for this entry even though it was set using + // the decomposed list below. A raw per-field/array comparison between the two + // representations would (incorrectly) treat this as a mismatch. + // + // The principal is the well-known "Everyone" SID rather than, say, LocalSystem: since this + // is a `_purge: true` (the default) write, it replaces the DACL outright with only this one + // entry, and the test process must remain able to read (and ultimately clean up) the key + // afterward. + let decomposed_json = r#"{"keyPath":"HKCU\\DSCTestNativeAlias","accessControlList":{"discretionary":[{"principal":"S-1-1-0","type":"allow","accessMask":["queryValue","setValue","createSubKey","enumerateSubKeys","notify","createLink","delete","readPermissions","changePermissions","takeOwnership"]}],"discretionaryProtected":false}}"#; + RegistryHelper::new_from_json(decomposed_json).unwrap().set().unwrap(); + + let result = RegistryHelper::new_from_json(decomposed_json).unwrap().test().unwrap(); + + assert_eq!(result.in_desired_state, Some(true), "{result:?}"); + + RegistryHelper::new_from_json(r#"{"keyPath":"HKCU\\DSCTestNativeAlias","_exist":false}"#).unwrap().remove().unwrap(); +} + +#[test] +fn set_rejects_access_control_list_with_no_sections() { + let helper = RegistryHelper::new_from_json(r#"{"keyPath":"HKCU\\DSCTestNativeEmptyAcl","accessControlList":{}}"#).unwrap(); + + let error = helper.set().unwrap_err(); + + assert!(matches!(error, RegistryError::Acl(_))); +} + +#[test] +fn registry_access_right_rejects_removed_wow64_variants() { + // `wow6464Key`/`wow6432Key` are registry *view* selectors for `RegOpenKeyExW`'s + // `samDesired`, not ACE permissions, and must no longer be accepted as accessMask values. + let wow64_64: Result = + serde_json::from_str("\"wow6464Key\""); + let wow64_32: Result = + serde_json::from_str("\"wow6432Key\""); + assert!(wow64_64.is_err()); + assert!(wow64_32.is_err()); +} + +#[test] +fn registry_access_right_generic_variants_have_expected_bits() { + use crate::config::RegistryAccessRight; + use lib_acl::AccessMask; + + assert_eq!(RegistryAccessRight::GenericRead.bits(), 0x8000_0000); + assert_eq!(RegistryAccessRight::GenericWrite.bits(), 0x4000_0000); + assert_eq!(RegistryAccessRight::GenericExecute.bits(), 0x2000_0000); + assert_eq!(RegistryAccessRight::GenericAll.bits(), 0x1000_0000); +} + +#[test] +fn set_what_if_with_value_name_returns_effective_acl_for_existing_key() { + // Note the doubled backslash: `key_path` is embedded directly into JSON string literals + // below, so it must already be JSON-escaped. + let key_path = r"HKCU\\DSCTestNativeWhatIfExisting"; + // Create the key (and an initial ACE) first, representing an "existing key" scenario. The + // principal is the well-known "Everyone" SID rather than, say, LocalSystem: this is a + // `_purge: true` (the default) write that replaces the DACL outright with only this one + // entry, and the test process must remain able to read (and ultimately clean up) the key + // afterward. + let initial_json = format!( + r#"{{"keyPath":"{key_path}","accessControlList":{{"discretionary":[{{"principal":"S-1-1-0","type":"allow","accessMask":["fullControl"]}}]}}}}"# + ); + RegistryHelper::new_from_json(&initial_json).unwrap().set().unwrap(); + + // What-if a Set that both writes a value AND merges in a new ACE (`_purge: false`); since + // the key already exists, the preview must report the full effective (merged) ACL -- just + // like an actual Set would compute and apply -- not the raw, unmerged request. + let what_if_json = format!( + r#"{{"keyPath":"{key_path}","valueName":"Hello","valueData":{{"String":"World"}},"accessControlList":{{"discretionary":[{{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}}],"_purge":false}}}}"# + ); + let mut helper = RegistryHelper::new_from_json(&what_if_json).unwrap(); + helper.enable_what_if(); + + let result = helper.set().unwrap().expect("what-if returns a preview"); + + let principals: Vec = result + .access_control_list + .unwrap() + .discretionary + .unwrap() + .into_iter() + .map(|entry| entry.principal) + .collect(); + assert!(principals.contains(&"S-1-1-0".to_string()), "{principals:?}"); + assert!(principals.contains(&"S-1-5-19".to_string()), "{principals:?}"); + + // What-if must not have actually written the value. + let actual = RegistryHelper::new_from_json(&format!( + r#"{{"keyPath":"{key_path}","valueName":"Hello"}}"# + )) + .unwrap() + .get() + .unwrap(); + assert_eq!(actual.exist, Some(false)); + + RegistryHelper::new_from_json(&format!(r#"{{"keyPath":"{key_path}"}}"#)) + .unwrap() + .remove() + .unwrap(); +} + +#[test] +fn set_what_if_with_value_name_returns_raw_acl_for_missing_key() { + let what_if_json = r#"{"keyPath":"HKCU\\DSCTestNativeWhatIfMissing","valueName":"Hello","valueData":{"String":"World"},"accessControlList":{"discretionary":[{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}]}}"#; + let mut helper = RegistryHelper::new_from_json(what_if_json).unwrap(); + helper.enable_what_if(); + + let result = helper.set().unwrap().expect("what-if returns a preview"); + + // The key doesn't exist, so there is no current state to merge with; the preview reports + // the raw request rather than attempting (and failing) to read a nonexistent key. + let principals: Vec = result + .access_control_list + .unwrap() + .discretionary + .unwrap() + .into_iter() + .map(|entry| entry.principal) + .collect(); + assert_eq!(principals, vec!["S-1-5-19".to_string()]); +} diff --git a/lib/lib-acl/src/error.rs b/lib/lib-acl/src/error.rs index eaa757b6d..5e2033838 100644 --- a/lib/lib-acl/src/error.rs +++ b/lib/lib-acl/src/error.rs @@ -38,4 +38,26 @@ pub enum AclError { /// object-specific ACE) was encountered while reading an existing ACL. #[error("Access control entry type {0} is not supported")] UnsupportedAceType(u8), + + /// An access mask read from an existing access control entry contains one or more bits that + /// don't correspond to any value in the resource's [`crate::model::AccessMask`] enum. + /// Returned instead of silently discarding the unrecognized bits, which would otherwise let + /// a subsequent merge-and-rewrite of the access control list narrow its effective access + /// mask without any indication that it had done so. + #[error( + "Access mask {0:#010x} includes one or more bits that are not recognized by this \ + resource's permission set and would be silently discarded" + )] + UnrepresentableAccessMask(u32), + + /// The discretionary or system access control list is NULL (absent), which Windows treats + /// as granting unrestricted access (for a DACL) or performing no auditing (for a SACL). This + /// is distinct from a present-but-empty access control list, which denies all access by + /// default; the two are never conflated, to avoid reporting (or comparing against) a more + /// permissive state as if it were the safe, empty one. + #[error( + "The {0} access control list is NULL (absent), which Windows treats as unrestricted; \ + this is not supported as equivalent to an empty access control list" + )] + NullAcl(&'static str), } diff --git a/lib/lib-acl/src/model.rs b/lib/lib-acl/src/model.rs index f941f24a6..4a2fec2ff 100644 --- a/lib/lib-acl/src/model.rs +++ b/lib/lib-acl/src/model.rs @@ -33,14 +33,24 @@ pub fn mask_to_bits(values: &[P]) -> u32 { /// Decomposes a raw bitmask into a list of access mask values. Candidates with more bits are /// preferred so that shorthand aliases (for example `FullControl`) are chosen over a long list -/// of individual atomic rights when they fit exactly. Bits that do not correspond to any known -/// value are dropped; round-tripping a mask through [`mask_to_bits`] and back is therefore not -/// guaranteed to reproduce the original list, only an equivalent mask for the bits this resource -/// understands. -#[must_use] -pub fn bits_to_mask(bits: u32) -> Vec

{ +/// of individual atomic rights when they fit exactly. +/// +/// Every bit in `bits` must be accounted for by the values this decomposition selects; bits that +/// don't correspond to any known value are never silently discarded; see Errors below. When this +/// function succeeds, the selected values' bits, recombined with [`mask_to_bits`], are guaranteed +/// to reproduce `bits` exactly. +/// +/// # Errors +/// +/// Returns [`AclError::UnrepresentableAccessMask`] when `bits` contains one or more bits that +/// don't correspond to any value in `P`. Silently dropping such bits would let a subsequent +/// merge-and-rewrite of the access control list narrow its effective access mask without any +/// indication that it had done so, so callers must surface this error (for example by failing +/// the read, or the write it would otherwise feed into) rather than treat the decomposition as +/// complete. +pub fn bits_to_mask(bits: u32) -> Result, AclError> { if bits == 0 { - return Vec::new(); + return Ok(Vec::new()); } let mut candidates: Vec

= P::all().to_vec(); @@ -55,7 +65,10 @@ pub fn bits_to_mask(bits: u32) -> Vec

{ remaining &= !candidate_bits; } } - result + if remaining != 0 { + return Err(AclError::UnrepresentableAccessMask(remaining)); + } + Ok(result) } /// The kind of access control entry. @@ -189,17 +202,25 @@ pub struct AccessControlList

{ /// The discretionary access control list (DACL) entries. Omit to leave the DACL unmanaged. #[serde(skip_serializing_if = "Option::is_none")] pub discretionary: Option>>, - /// Whether the DACL is protected from inheriting entries from its parent. Omit to leave the - /// current protection state unchanged when `discretionary` is not provided, or to request - /// an unprotected (inheriting) DACL when it is. + /// Whether the DACL is protected from inheriting entries from its parent. Only meaningful + /// when `discretionary` is also provided; has no effect otherwise. Omitting this property + /// behaves according to `_purge`: when purging (the default), the DACL is written as + /// unprotected (inheriting), since a purge write always stamps an explicit protection state; + /// when merging (`_purge: false`), the current protection state is preserved unchanged, since + /// a merge leaves everything it doesn't explicitly touch alone. Set this explicitly to + /// request a particular protection state regardless of `_purge`. #[serde(skip_serializing_if = "Option::is_none")] pub discretionary_protected: Option, /// The system access control list (SACL) entries. Omit to leave the SACL unmanaged. #[serde(skip_serializing_if = "Option::is_none")] pub system: Option>>, - /// Whether the SACL is protected from inheriting entries from its parent. Omit to leave the - /// current protection state unchanged when `system` is not provided, or to request an - /// unprotected (inheriting) SACL when it is. + /// Whether the SACL is protected from inheriting entries from its parent. Only meaningful + /// when `system` is also provided; has no effect otherwise. Omitting this property behaves + /// according to `_purge`: when purging (the default), the SACL is written as unprotected + /// (inheriting), since a purge write always stamps an explicit protection state; when + /// merging (`_purge: false`), the current protection state is preserved unchanged, since a + /// merge leaves everything it doesn't explicitly touch alone. Set this explicitly to request + /// a particular protection state regardless of `_purge`. #[serde(skip_serializing_if = "Option::is_none")] pub system_protected: Option, /// Whether to replace (purge) each supplied section outright, or merge its entries into the @@ -207,9 +228,11 @@ pub struct AccessControlList

{ /// preserves the historical full-replacement behavior. When `false`, the entries supplied /// for a section are merged into that section's existing entries: semantic duplicates (same /// normalized principal, ACE type, effective access mask, and inheritance/audit flags) are - /// not duplicated, and the resulting discretionary section is canonicalized with every - /// `deny` entry before every `allow` entry. A section that is omitted from the request - /// remains unmanaged regardless of this setting. + /// not duplicated, and the resulting discretionary section is canonicalized into Windows' + /// canonical ACE order: every explicit `deny` entry, then every explicit `allow` entry, then + /// every inherited entry (preserving its existing relative order) -- an inherited `deny` + /// entry is never moved ahead of an explicit `allow` entry. A section that is omitted from + /// the request remains unmanaged regardless of this setting. #[serde(rename = "_purge", skip_serializing_if = "Option::is_none")] #[schemars(default = "default_purge")] pub purge: Option, @@ -262,8 +285,8 @@ impl AccessControlList

{ /// an equal principal (expected to already be normalized, for example a resolved SID string), /// an equal ACE type, an equal effective access mask (decomposed to its raw bits so that /// aliased permission names, such as a `fullControl` shorthand versus the individual rights it -/// implies, still compare equal), equal inheritance flags (ignoring [`AceFlag::Inherited`], -/// which reflects only provenance and is never meaningful in a request), and equal audit flags. +/// implies, still compare equal), equal inheritance flags, and equal audit flags. Inherited and +/// explicit entries remain distinct because Windows applies and orders them differently. #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct AceSemanticKey { principal: String, @@ -273,34 +296,75 @@ struct AceSemanticKey { audit_flags: Vec, } -fn semantic_key(entry: &AccessControlEntry

) -> AceSemanticKey { - let mut flags: Vec = entry - .flags - .iter() - .copied() - .filter(|flag| *flag != AceFlag::Inherited) - .collect(); +/// Sorts and dedups a copy of `flags`, so that two flag lists that set the same flags compare +/// equal regardless of the order they were specified or read back in. +fn normalized_flags(flags: &[AceFlag]) -> Vec { + let mut flags = flags.to_vec(); flags.sort_unstable(); flags.dedup(); + flags +} - let mut audit_flags = entry.audit_flags.clone(); - audit_flags.sort_unstable(); - audit_flags.dedup(); +/// Sorts and dedups a copy of `flags`, so that two audit flag lists that set the same flags +/// compare equal regardless of the order they were specified or read back in. +fn normalized_audit_flags(flags: &[AuditFlag]) -> Vec { + let mut flags = flags.to_vec(); + flags.sort_unstable(); + flags.dedup(); + flags +} +fn semantic_key(entry: &AccessControlEntry

) -> AceSemanticKey { AceSemanticKey { principal: entry.principal.clone(), ace_type: entry.ace_type, mask_bits: mask_to_bits(&entry.access_mask), - flags, - audit_flags, + flags: normalized_flags(&entry.flags), + audit_flags: normalized_audit_flags(&entry.audit_flags), } } +/// Returns whether `a` and `b` are the same access control entry in substance: an equal +/// principal, an equal ACE type, the same *effective* access mask (decomposed to raw bits, so +/// that aliased permission names such as a `fullControl` shorthand compare equal to the +/// individual rights it implies), and the same normalized (order-independent) sets of +/// inheritance flags and audit flags. +fn entries_semantically_equal( + a: &AccessControlEntry

, + b: &AccessControlEntry

, +) -> bool { + a.principal == b.principal + && a.ace_type == b.ace_type + && mask_to_bits(&a.access_mask) == mask_to_bits(&b.access_mask) + && normalized_flags(&a.flags) == normalized_flags(&b.flags) + && normalized_audit_flags(&a.audit_flags) == normalized_audit_flags(&b.audit_flags) +} + +/// Returns whether `desired` and `actual` contain the same access control entries in the same +/// order (see [`entries_semantically_equal`] for what "same" means for a single entry pair). +/// Used to compare a `_purge: true` section, which (unlike a `_purge: false` merge) must match +/// position-for-position: a purge write replaces the section outright, verbatim and in the +/// order given, so convergence requires the same count, order, and inherited status throughout, +/// not merely an equivalent unordered set of entries. +fn entries_match_in_order( + desired: &[AccessControlEntry

], + actual: &[AccessControlEntry

], +) -> bool { + desired.len() == actual.len() + && desired + .iter() + .zip(actual.iter()) + .all(|(desired, actual)| entries_semantically_equal(desired, actual)) +} + /// Merges `requested` access control entries into `current`, dropping any requested entry that /// is a semantic duplicate (see [`AceSemanticKey`]) of an entry already present in `current` or -/// earlier in `requested`, and then canonicalizing the result so that every `deny` entry -/// precedes every `allow`/`audit` entry, preserving the relative order of entries within each -/// group. Both inputs are expected to already have normalized (for example resolved-SID) +/// earlier in `requested`, and then canonicalizing the result into Windows' canonical ACE order: +/// every explicit (non-inherited) `deny` entry, then every explicit `allow`/`audit` entry, then +/// every inherited entry, preserving the relative order of entries within each of those three +/// groups. In particular, an inherited `deny` entry is never moved ahead of an explicit `allow` +/// entry, since Windows always places explicit entries before inherited ones regardless of +/// allow/deny. Both inputs are expected to already have normalized (for example resolved-SID) /// principals. #[must_use] pub fn merge_entries( @@ -314,21 +378,40 @@ pub fn merge_entries( merged.push(entry.clone()); } } - // `sort_by_key` is a stable sort, so entries within the `deny` group and within the - // `allow`/`audit` group keep their relative order. - merged.sort_by_key(|entry| entry.ace_type != AceType::Deny); + // `sort_by_key` is a stable sort, so entries within each of the three groups below keep + // their relative order. + merged.sort_by_key(|entry| canonical_ace_rank(entry)); merged } +/// The sort rank used by [`merge_entries`] to canonicalize ACE order: explicit `deny` entries +/// sort first, explicit `allow`/`audit` entries second, and every inherited entry (`deny` or +/// `allow`/`audit` alike) last, regardless of its own type. +fn canonical_ace_rank(entry: &AccessControlEntry

) -> u8 { + if entry.flags.contains(&AceFlag::Inherited) { + 2 + } else if entry.ace_type == AceType::Deny { + 0 + } else { + 1 + } +} + /// Computes the effective access control list that should be written (or compared against) for /// `requested`, given its [`AccessControlList::purge`] setting and the `current` state of the /// section(s) `requested` manages. Both `current` and `requested` are expected to already have /// normalized (for example resolved-SID) principals. /// /// When `purge` is `true` (the default), each supplied section is replaced outright: the result -/// is `requested`'s section verbatim. When `purge` is `false`, each supplied section is merged -/// with the corresponding section of `current` (see [`merge_entries`]); a protection flag that -/// is not explicitly supplied in `requested` preserves `current`'s value instead of resetting it. +/// is `requested`'s section verbatim. A protection flag that is not explicitly supplied in +/// `requested` defaults to `false` in this case, matching the real effect of writing a section +/// with no protection state requested (see [`crate::win32::build_self_relative_security_descriptor`]): +/// the write always stamps an explicit protected/unprotected state, so a desired-state comparison +/// must not treat the omitted flag as "don't care" and silently accept a protected or unprotected +/// `actual` state at random. When `purge` is `false`, each supplied section is merged with the +/// corresponding section of `current` (see [`merge_entries`]); a protection flag that is not +/// explicitly supplied in `requested` instead preserves `current`'s value, since a merge leaves +/// everything it doesn't explicitly touch alone. /// /// A section that is not present (`None`) in `requested` is left unmanaged and is always `None` /// in the result, regardless of `current`. @@ -348,8 +431,12 @@ pub fn merge_access_control_list( }); let discretionary_protected = requested.discretionary.as_ref().and_then(|_| { if purge { - requested.discretionary_protected + // A purge write always stamps an explicit protection state, so the omitted flag + // resolves to its effective `false` default rather than staying unresolved. + Some(requested.discretionary_protected.unwrap_or(false)) } else { + // A merge leaves everything it doesn't explicitly touch alone: fall back to + // `current`'s value, which may itself be `None` (unknown) rather than `false`. requested .discretionary_protected .or(current.discretionary_protected) @@ -365,8 +452,12 @@ pub fn merge_access_control_list( }); let system_protected = requested.system.as_ref().and_then(|_| { if purge { - requested.system_protected + // A purge write always stamps an explicit protection state, so the omitted flag + // resolves to its effective `false` default rather than staying unresolved. + Some(requested.system_protected.unwrap_or(false)) } else { + // A merge leaves everything it doesn't explicitly touch alone: fall back to + // `current`'s value, which may itself be `None` (unknown) rather than `false`. requested.system_protected.or(current.system_protected) } }); @@ -383,11 +474,16 @@ pub fn merge_access_control_list( /// Returns whether `actual` satisfies `desired`, an access control list that has already been /// passed through [`merge_access_control_list`] (so that, when `desired`'s /// [`purge`](AccessControlList::purge) is `false`, it reflects the full effective merge rather -/// than only the partial, newly requested entries). +/// than only the partial, newly requested entries, and a protection flag omitted from the +/// original request has already been resolved to its effective value). /// /// A section that is `None` in `desired` is always satisfied (it is unmanaged). When `desired`'s -/// `purge` is `true` (the default), a managed section must match `actual` exactly, including -/// order, mirroring the full-replacement semantics used when writing it. When `false`, it is +/// `purge` is `true` (the default), a managed section must match `actual` entry-for-entry, in +/// the same order and with the same count and inherited status throughout (since a purge write +/// replaces the section outright, verbatim and in the order given) -- but each entry pair is +/// compared *semantically* (see [`entries_semantically_equal`]), so an aliased permission name +/// (for example `fullControl` versus the individual rights it implies) or a differently ordered +/// flag list that is otherwise equivalent does not cause a false mismatch. When `false`, it is /// enough for `actual` to contain a semantic duplicate (see [`AceSemanticKey`]) of every entry in /// `desired`; additional entries present only in `actual` do not cause a mismatch. A protection /// flag that is not explicitly supplied in `desired` is always considered satisfied. @@ -412,7 +508,7 @@ fn section_satisfied( return false; }; if purge { - desired_entries == actual_entries + entries_match_in_order(desired_entries, actual_entries) } else { let desired_keys: HashSet<_> = desired_entries.iter().map(semantic_key).collect(); let actual_keys: HashSet<_> = actual_entries.iter().map(semantic_key).collect(); @@ -548,18 +644,17 @@ mod tests { } #[test] - fn merge_drops_duplicates_differing_only_by_inherited_flag() { + fn merge_keeps_explicit_and_inherited_entries_distinct() { let mut inherited = entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]); inherited.flags = vec![AceFlag::Inherited]; let current = vec![inherited]; - // The same entry, requested explicitly (never carries the Inherited flag). let requested = vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]; let merged = merge_entries(¤t, &requested); - assert_eq!(merged.len(), 1); - // The existing, inherited entry is preserved rather than being replaced. - assert!(merged[0].flags.contains(&AceFlag::Inherited)); + assert_eq!(merged.len(), 2); + assert!(!merged[0].flags.contains(&AceFlag::Inherited)); + assert!(merged[1].flags.contains(&AceFlag::Inherited)); } #[test] @@ -674,4 +769,170 @@ mod tests { assert_eq!(effective.discretionary_protected, Some(false)); } + + #[test] + fn purge_true_defaults_protected_to_false_when_omitted() { + // `current` is protected, but a purge write always stamps an explicit protection + // state, so omitting it in `requested` must reset the effective state to false rather + // than silently inheriting `current`'s protected state. + let current = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + discretionary_protected: Some(true), + ..Default::default() + }; + let requested = AccessControlList { + discretionary: Some(vec![entry("S-1-1-1", AceType::Allow, vec![TestRight::Write])]), + purge: Some(true), + ..Default::default() + }; + + let effective = merge_access_control_list(¤t, &requested); + + assert_eq!(effective.discretionary_protected, Some(false)); + } + + #[test] + fn is_satisfied_is_false_when_purge_true_omits_protected_but_actual_is_protected() { + let actual = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + discretionary_protected: Some(true), + ..Default::default() + }; + let desired = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + purge: Some(true), + ..Default::default() + }; + let effective_desired = merge_access_control_list(&actual, &desired); + + // The omitted protection flag defaults to false under purge, so a currently-protected + // actual state must not be reported as satisfied. + assert!(!is_satisfied(&effective_desired, &actual)); + } + + #[test] + fn merge_keeps_explicit_entries_before_inherited_entries_preserving_inherited_order() { + let mut inherited_allow = entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]); + inherited_allow.flags = vec![AceFlag::Inherited]; + let mut inherited_deny = entry("S-1-1-1", AceType::Deny, vec![TestRight::Write]); + inherited_deny.flags = vec![AceFlag::Inherited]; + // `current` reflects the real, already-canonical order read from the system: the + // inherited allow entry happens to appear before the inherited deny entry. + let current = vec![inherited_allow, inherited_deny]; + let requested = vec![entry("S-1-1-2", AceType::Allow, vec![TestRight::Read])]; + + let merged = merge_entries(¤t, &requested); + + let principals: Vec<&str> = merged.iter().map(|entry| entry.principal.as_str()).collect(); + // The explicit allow entry comes first (explicit entries always precede inherited + // ones), and the two inherited entries keep their original relative order: the + // inherited deny is NOT moved ahead of the explicit allow, even though deny entries are + // otherwise ordered before allow entries among explicit entries. + assert_eq!(principals, vec!["S-1-1-2", "S-1-1-0", "S-1-1-1"]); + } + + #[test] + fn bits_to_mask_errors_on_unrepresentable_bits() { + // 0x8 doesn't correspond to any `TestRight` value. + let result = bits_to_mask::(0x8); + assert!(matches!(result, Err(AclError::UnrepresentableAccessMask(0x8)))); + } + + #[test] + fn bits_to_mask_round_trips_known_bits() { + let decomposed = bits_to_mask::(0x3).unwrap(); + assert_eq!(mask_to_bits(&decomposed), 0x3); + } + + #[test] + fn is_satisfied_purge_true_treats_alias_and_decomposed_mask_as_equal() { + // `FullControl` (0x3) is the same effective mask as `Read` (0x1) + `Write` (0x2). + let desired = AccessControlList { + discretionary: Some(vec![entry( + "S-1-1-0", + AceType::Allow, + vec![TestRight::FullControl], + )]), + discretionary_protected: Some(false), + purge: Some(true), + ..Default::default() + }; + let actual = AccessControlList { + discretionary: Some(vec![entry( + "S-1-1-0", + AceType::Allow, + vec![TestRight::Read, TestRight::Write], + )]), + discretionary_protected: Some(false), + ..Default::default() + }; + + assert!(is_satisfied(&desired, &actual)); + } + + #[test] + fn is_satisfied_purge_true_treats_differently_ordered_flags_as_equal() { + let mut desired_entry = entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]); + desired_entry.flags = vec![AceFlag::ContainerInherit, AceFlag::ObjectInherit]; + let mut actual_entry = entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]); + actual_entry.flags = vec![AceFlag::ObjectInherit, AceFlag::ContainerInherit]; + let desired = AccessControlList { + discretionary: Some(vec![desired_entry]), + discretionary_protected: Some(false), + purge: Some(true), + ..Default::default() + }; + let actual = AccessControlList { + discretionary: Some(vec![actual_entry]), + discretionary_protected: Some(false), + ..Default::default() + }; + + assert!(is_satisfied(&desired, &actual)); + } + + #[test] + fn is_satisfied_purge_true_still_requires_matching_order_and_count() { + let allow = entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]); + let deny = entry("S-1-1-1", AceType::Deny, vec![TestRight::Write]); + let desired = AccessControlList { + discretionary: Some(vec![allow.clone(), deny.clone()]), + discretionary_protected: Some(false), + purge: Some(true), + ..Default::default() + }; + // Same two entries, but in a different order: a purge write would produce the exact + // order given, so a differently ordered `actual` is not an equivalent, converged state + // even though the same entries (by alias-aware comparison) are all present. + let actual = AccessControlList { + discretionary: Some(vec![deny, allow]), + discretionary_protected: Some(false), + ..Default::default() + }; + + assert!(!is_satisfied(&desired, &actual)); + } + + #[test] + fn is_satisfied_purge_true_distinguishes_inherited_status() { + let desired_entry = entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]); + let mut actual_entry = entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]); + actual_entry.flags = vec![AceFlag::Inherited]; + let desired = AccessControlList { + discretionary: Some(vec![desired_entry]), + discretionary_protected: Some(false), + purge: Some(true), + ..Default::default() + }; + let actual = AccessControlList { + discretionary: Some(vec![actual_entry]), + discretionary_protected: Some(false), + ..Default::default() + }; + + // A purge write never produces an inherited entry; an inherited `actual` entry does + // not satisfy a `desired` entry that isn't marked as inherited, even though every other + // aspect (principal, type, effective mask) matches. + assert!(!is_satisfied(&desired, &actual)); + } } diff --git a/lib/lib-acl/src/win32/acl.rs b/lib/lib-acl/src/win32/acl.rs index 5c9f4457a..0da4b3f2b 100644 --- a/lib/lib-acl/src/win32/acl.rs +++ b/lib/lib-acl/src/win32/acl.rs @@ -121,6 +121,7 @@ fn inheritance_flags_to_bits(flags: &[AceFlag]) -> u32 { }) } +/// Decodes the subset of `AceFlags` bits this crate models back into [`AceFlag`] values. fn inheritance_flags_from_bits(bits: u32) -> Vec { let mut flags = Vec::new(); if bits & CONTAINER_INHERIT_ACE.0 != 0 { @@ -157,17 +158,23 @@ fn audit_flags_from_bits(bits: u32) -> Vec { /// Parses an existing ACL into a list of access control entries. /// +/// # Safety +/// +/// `acl_ptr` must point to a single, valid, initialized `ACL` structure (for example one +/// obtained from `GetSecurityDescriptorDacl`/`GetSecurityDescriptorSacl` on a valid security +/// descriptor), valid for the duration of this call. +/// /// # Errors /// /// Returns [`AclError::UnsupportedAceType`] when the ACL contains an entry type this crate does /// not model (for example an object-specific or conditional ACE), or [`AclError`] when a /// lower-level Win32 API call fails. -pub(crate) fn parse_acl( +pub(crate) unsafe fn parse_acl( acl_ptr: *mut ACL, ) -> Result>, AclError> { let mut size_info = ACL_SIZE_INFORMATION::default(); - // SAFETY: acl_ptr is a valid, initialized ACL pointer; size_info is a writable local - // variable of the correct size for AclSizeInformation. + // SAFETY: the caller guarantees acl_ptr is a valid, initialized ACL pointer; size_info is a + // writable local variable of the correct size for AclSizeInformation. unsafe { GetAclInformation( acl_ptr, @@ -222,14 +229,17 @@ pub(crate) fn parse_acl( return Err(AclError::UnsupportedAceType(header.AceType)); }; - let principal = sid_to_string(windows::Win32::Security::PSID(sid_ptr.cast_mut()))?; + // SAFETY: sid_ptr points at the SID embedded in the ACE that GetAce just returned, + // which is valid for as long as acl_ptr (borrowed by this function's caller) is valid; + // it is a real, Windows-populated SID, not externally supplied bytes. + let principal = unsafe { sid_to_string(windows::Win32::Security::PSID(sid_ptr.cast_mut())) }?; let flags = inheritance_flags_from_bits(ace_flags_bits); let audit_flags = if ace_type == AceType::Audit { audit_flags_from_bits(ace_flags_bits) } else { Vec::new() }; - let access_mask = bits_to_mask::

(mask); + let access_mask = bits_to_mask::

(mask)?; entries.push(AccessControlEntry { principal, diff --git a/lib/lib-acl/src/win32/security_descriptor.rs b/lib/lib-acl/src/win32/security_descriptor.rs index d882b158e..f73ea69b5 100644 --- a/lib/lib-acl/src/win32/security_descriptor.rs +++ b/lib/lib-acl/src/win32/security_descriptor.rs @@ -45,10 +45,22 @@ pub fn read_security_information( /// Reads the requested sections of an existing, self-relative security descriptor buffer into /// the portable model. /// +/// # Safety +/// +/// `descriptor_bytes` must contain a single, complete, well-formed self-relative +/// `SECURITY_DESCRIPTOR` (for example a buffer returned by `RegGetKeySecurity`, +/// `GetFileSecurityW`, or [`build_self_relative_security_descriptor`]/`MakeSelfRelativeSD`), with +/// every internal offset it encodes pointing within the bounds of `descriptor_bytes`. Passing +/// arbitrary, truncated, or otherwise unvalidated bytes is undefined behavior: the Win32 APIs +/// this function calls trust the descriptor's internal structure and offsets without further +/// bounds checking. +/// /// # Errors /// -/// Returns [`AclError`] when the descriptor is malformed or contains an unsupported ACE type. -pub fn read_access_control_list( +/// Returns [`AclError`] when the descriptor is malformed or contains an unsupported ACE type, or +/// [`AclError::NullAcl`] when a requested section's underlying access control list is NULL +/// (absent) rather than present-but-empty. +pub unsafe fn read_access_control_list( descriptor_bytes: &mut [u8], want_discretionary: bool, want_system: bool, @@ -56,7 +68,8 @@ pub fn read_access_control_list( let descriptor = PSECURITY_DESCRIPTOR(descriptor_bytes.as_mut_ptr().cast::()); let mut control = 0_u16; let mut revision = 0_u32; - // SAFETY: descriptor points at a valid, self-relative security descriptor buffer. + // SAFETY: the caller guarantees descriptor_bytes is a valid, self-relative security + // descriptor buffer. unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) }.map_err( |error| AclError::Win32 { operation: "GetSecurityDescriptorControl", @@ -73,12 +86,15 @@ pub fn read_access_control_list( }; if want_discretionary { - let (entries, protected) = read_acl_section(descriptor, control, false)?; + // SAFETY: the caller guarantees descriptor_bytes is a valid, self-relative security + // descriptor buffer. + let (entries, protected) = unsafe { read_acl_section(descriptor, control, false) }?; result.discretionary = Some(entries); result.discretionary_protected = Some(protected); } if want_system { - let (entries, protected) = read_acl_section(descriptor, control, true)?; + // SAFETY: see above. + let (entries, protected) = unsafe { read_acl_section(descriptor, control, true) }?; result.system = Some(entries); result.system_protected = Some(protected); } @@ -86,7 +102,12 @@ pub fn read_access_control_list( Ok(result) } -fn read_acl_section( +/// # Safety +/// +/// `descriptor` must point at a valid, self-relative security descriptor (see +/// [`read_access_control_list`]'s contract), and `control` must be the control word that +/// `GetSecurityDescriptorControl` returned for that same descriptor. +unsafe fn read_acl_section( descriptor: PSECURITY_DESCRIPTOR, control: u16, sacl: bool, @@ -96,8 +117,8 @@ fn read_acl_section( let mut defaulted = BOOL::default(); let mut acl_ptr = core::ptr::null_mut::(); - // SAFETY: descriptor is a valid security descriptor; the output pointers are writable - // locals. + // SAFETY: the caller guarantees descriptor is a valid security descriptor; the output + // pointers are writable locals. if sacl { unsafe { GetSecurityDescriptorSacl(descriptor, &mut present, &mut acl_ptr, &mut defaulted) } } else { @@ -112,11 +133,20 @@ fn read_acl_section( code: error.code().0 as u32, })?; - let entries = if present.as_bool() && !acl_ptr.is_null() { - parse_acl::

(acl_ptr)? - } else { - Vec::new() - }; + // A NULL access control list (the list is marked present, but the pointer to it is NULL -- + // or, more rarely, not marked present at all) means Windows applies no discretionary + // protection (DACL) or no auditing (SACL): a DACL-less object grants unrestricted access to + // everyone. This is a materially different, far more permissive state than a present but + // empty list (which denies all access by default), so it must never be reported as + // equivalent to one; doing so could make a desired-state comparison silently report a + // dangerously open object as satisfied by an empty-list request. + if !present.as_bool() || acl_ptr.is_null() { + return Err(AclError::NullAcl(if sacl { "system" } else { "discretionary" })); + } + + // SAFETY: the caller guarantees descriptor (and therefore acl_ptr, which GetSecurityDescriptorDacl/ + // Sacl just populated from it) is valid. + let entries = unsafe { parse_acl::

(acl_ptr) }?; let protected_bit = if sacl { SE_SACL_PROTECTED.0 @@ -299,7 +329,9 @@ fn normalize_entries( .iter() .map(|entry| { let sid = resolve_principal(&entry.principal)?; - let principal = sid_to_string(sid.as_psid())?; + // SAFETY: sid was just produced by resolve_principal, which guarantees valid, + // well-formed SID bytes owned by `sid` for the duration of this call. + let principal = unsafe { sid_to_string(sid.as_psid()) }?; Ok(AccessControlEntry { principal, ..entry.clone() @@ -307,3 +339,69 @@ fn normalize_entries( }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum TestRight { + Read, + } + + impl AccessMask for TestRight { + fn bits(self) -> u32 { + match self { + Self::Read => 0x1, + } + } + + fn all() -> &'static [Self] { + &[Self::Read] + } + } + + /// Builds a self-relative security descriptor buffer with a DACL that is either absent + /// (`dacl: None`, a NULL DACL) or present (`dacl: Some(entries)`, possibly an empty slice + /// for a present-but-empty DACL). + fn build_descriptor_with_dacl(dacl: Option<&[AccessControlEntry]>) -> Vec { + let mut descriptor = SECURITY_DESCRIPTOR::default(); + let descriptor_ptr = PSECURITY_DESCRIPTOR((&raw mut descriptor).cast::()); + // SAFETY: descriptor is a local, writable SECURITY_DESCRIPTOR. + unsafe { InitializeSecurityDescriptor(descriptor_ptr, SECURITY_DESCRIPTOR_REVISION) } + .unwrap(); + + let mut buffer = dacl.map(|entries| AclBuffer::build(entries).unwrap()); + let dacl_ptr = buffer.as_mut().map(|buffer| buffer.as_ptr().cast_const()); + // SAFETY: descriptor_ptr was just initialized above; when dacl_ptr is Some, its pointee + // (buffer) outlives this call and the conversion to self-relative form below. + unsafe { SetSecurityDescriptorDacl(descriptor_ptr, true, dacl_ptr, false) }.unwrap(); + + let bytes = to_self_relative(descriptor_ptr).unwrap(); + drop(buffer); + bytes + } + + #[test] + fn read_access_control_list_rejects_null_dacl() { + let mut bytes = build_descriptor_with_dacl(None); + + // SAFETY: bytes was just built above as a well-formed self-relative descriptor. + let result = unsafe { read_access_control_list::(&mut bytes, true, false) }; + + // A NULL DACL must never be silently reported as an empty one: it means unrestricted + // access, the opposite of what an empty (deny-all) DACL means. + assert!(matches!(result, Err(AclError::NullAcl("discretionary")))); + } + + #[test] + fn read_access_control_list_accepts_present_empty_dacl() { + let mut bytes = build_descriptor_with_dacl(Some(&[])); + + // SAFETY: bytes was just built above as a well-formed self-relative descriptor. + let result = unsafe { read_access_control_list::(&mut bytes, true, false) } + .expect("a present, empty DACL must be read successfully, not rejected"); + + assert_eq!(result.discretionary, Some(Vec::new())); + } +} diff --git a/lib/lib-acl/src/win32/sid.rs b/lib/lib-acl/src/win32/sid.rs index 3ee7c1a4d..bb6df91b2 100644 --- a/lib/lib-acl/src/win32/sid.rs +++ b/lib/lib-acl/src/win32/sid.rs @@ -41,14 +41,21 @@ pub fn resolve_principal(principal: &str) -> Result { /// Converts a security identifier to its normalized string form (for example /// `S-1-5-32-544`). /// +/// # Safety +/// +/// `sid` must point to a valid, well-formed SID structure (for example one returned by +/// [`resolve_principal`], embedded in an ACE that was read from a genuine Windows ACL, or +/// otherwise populated by a Win32 API), readable for the entire length implied by its own +/// header, for the duration of this call. +/// /// # Errors /// /// Returns [`AclError::Win32`] when the conversion fails, or [`AclError::InvalidSid`] when the /// converted string is not valid UTF-16. -pub fn sid_to_string(sid: PSID) -> Result { +pub unsafe fn sid_to_string(sid: PSID) -> Result { let mut pwstr = PWSTR::null(); - // SAFETY: sid is a valid SID pointer for the duration of this call. The returned string is - // released via LocalFree below. + // SAFETY: the caller guarantees `sid` is a valid SID pointer for the duration of this call. + // The returned string is released via LocalFree below. unsafe { ConvertSidToStringSidW(sid, &mut pwstr) }.map_err(|error| AclError::Win32 { operation: "ConvertSidToStringSidW", code: error.code().0 as u32, @@ -65,8 +72,53 @@ pub fn sid_to_string(sid: PSID) -> Result { result } +/// Returns whether `value` has the lexical shape of a SID string (for example `S-1-5-32-544`), +/// so that [`resolve_principal`] attempts to parse it as one instead of looking it up as an +/// account or group name. Only strings that actually match the SID grammar -- a case-insensitive +/// `S`, then a hyphen-separated revision number, identifier authority, and zero or more +/// subauthorities, each a decimal number (the identifier authority may alternatively be a +/// `0x`-prefixed hexadecimal value) -- are treated as candidates. This intentionally excludes +/// hyphenated account or group names that merely start with `S` (for example `Sales-Team` or +/// `S-Corp-Admins`), which must go through account lookup instead. fn looks_like_sid(value: &str) -> bool { - value.len() > 1 && value.as_bytes()[0].eq_ignore_ascii_case(&b'S') && value.contains('-') + let Some(rest) = value.strip_prefix(['S', 's']) else { + return false; + }; + + let mut parts = rest.split('-'); + // Every SID string has a hyphen immediately after the leading `S`; a hyphenated name + // instead has more letters there (for example "ales-Team" from "Sales-Team"), which fails + // this check. + if parts.next() != Some("") { + return false; + } + + let is_decimal = |segment: &str| !segment.is_empty() && segment.bytes().all(|b| b.is_ascii_digit()); + // The identifier authority may instead be a `0x`-prefixed hexadecimal value (up to 48 bits). + let is_authority = |segment: &str| { + is_decimal(segment) + || segment + .strip_prefix("0x") + .or_else(|| segment.strip_prefix("0X")) + .is_some_and(|hex| !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())) + }; + + let Some(revision) = parts.next() else { + return false; + }; + if !is_decimal(revision) { + return false; + } + let Some(authority) = parts.next() else { + return false; + }; + if !is_authority(authority) { + return false; + } + + // Zero or more subauthorities; bail out on the first remaining segment that isn't a plain + // decimal number (for example a trailing hyphenated word) rather than accepting it as a SID. + parts.all(is_decimal) } fn parse_sid(value: &str) -> Result { @@ -137,3 +189,49 @@ fn lookup_account_name(name: &str) -> Result { Ok(OwnedSid(sid_buffer)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_well_known_sid_strings() { + assert!(looks_like_sid("S-1-1-0")); + assert!(looks_like_sid("S-1-5-7")); + assert!(looks_like_sid("S-1-5-32-544")); + assert!(looks_like_sid("S-1-5-21-3623811015-3361044348-30300820-1013")); + // Case-insensitive leading `S`. + assert!(looks_like_sid("s-1-5-32-544")); + // Hexadecimal identifier authority. + assert!(looks_like_sid("S-1-0x206-1-2-3")); + } + + #[test] + fn rejects_hyphenated_names_that_merely_start_with_s() { + // Account/group names that happen to start with `S` and contain hyphens must be + // resolved via account lookup, not parsed as if they were SID strings. + assert!(!looks_like_sid("Sales-Team")); + assert!(!looks_like_sid("S-Corp-Admins")); + assert!(!looks_like_sid("S-ales-Team")); + assert!(!looks_like_sid("Seattle-Users")); + } + + #[test] + fn rejects_malformed_sid_like_strings() { + assert!(!looks_like_sid("")); + assert!(!looks_like_sid("S")); + assert!(!looks_like_sid("S-")); + assert!(!looks_like_sid("S-1")); + // Non-numeric revision/authority segments. + assert!(!looks_like_sid("S-a-5")); + assert!(!looks_like_sid("S-1-a")); + // A non-numeric trailing segment is not a valid subauthority. + assert!(!looks_like_sid("S-1-5-Team")); + } + + #[test] + fn rejects_names_that_do_not_start_with_s() { + assert!(!looks_like_sid("DOMAIN\\User")); + assert!(!looks_like_sid("Administrators")); + } +} diff --git a/resources/file_dsc/file_dsc.dsc.manifests.json b/resources/file_dsc/file_dsc.dsc.manifests.json index a77d41cc2..dad19352d 100644 --- a/resources/file_dsc/file_dsc.dsc.manifests.json +++ b/resources/file_dsc/file_dsc.dsc.manifests.json @@ -193,7 +193,7 @@ "discretionaryProtected": { "type": "boolean", "title": "Discretionary access control list is protected", - "description": "Whether the DACL is protected from inheriting entries from its parent. Omit to leave the current protection state unchanged when 'discretionary' is not provided, or to request an unprotected (inheriting) DACL when it is." + "description": "Whether the DACL is protected from inheriting entries from its parent. Only meaningful when 'discretionary' is also provided. Omitting this property behaves according to '_purge': when purging (the default), the DACL is written as unprotected (inheriting), since a purge write always stamps an explicit protection state; when merging ('_purge': false), the current protection state is preserved unchanged. Set this explicitly to request a particular protection state regardless of '_purge'." }, "system": { "type": "array", @@ -206,12 +206,12 @@ "systemProtected": { "type": "boolean", "title": "System access control list is protected", - "description": "Whether the SACL is protected from inheriting entries from its parent. Omit to leave the current protection state unchanged when 'system' is not provided, or to request an unprotected (inheriting) SACL when it is." + "description": "Whether the SACL is protected from inheriting entries from its parent. Only meaningful when 'system' is also provided. Omitting this property behaves according to '_purge': when purging (the default), the SACL is written as unprotected (inheriting), since a purge write always stamps an explicit protection state; when merging ('_purge': false), the current protection state is preserved unchanged. Set this explicitly to request a particular protection state regardless of '_purge'." }, "_purge": { "type": "boolean", "title": "Purge supplied sections", - "description": "Whether to replace (purge) each supplied section outright, or merge its entries into the section's current managed entries. Defaults to true (replace) when omitted. When false, the entries supplied for a section are merged into that section's existing entries: semantic duplicates (same normalized principal, ACE type, effective access mask, and inheritance/audit flags) are not duplicated, and the resulting discretionary section is canonicalized with every 'deny' entry before every 'allow' entry. A section that is omitted from the request remains unmanaged regardless of this setting.", + "description": "Whether to replace (purge) each supplied section outright, or merge its entries into the section's current managed entries. Defaults to true (replace) when omitted. When false, the entries supplied for a section are merged into that section's existing entries: semantic duplicates (same normalized principal, ACE type, effective access mask, and inheritance/audit flags) are not duplicated, and the resulting discretionary section is canonicalized into Windows' canonical ACE order: every explicit 'deny' entry, then every explicit 'allow' entry, then every inherited entry (preserving its existing relative order) -- an inherited 'deny' entry is never moved ahead of an explicit 'allow' entry. A section that is omitted from the request remains unmanaged regardless of this setting. For a Test operation, '_purge': false is satisfied when every requested entry is present (after normalization) in the actual access control list; extra entries already present in the actual ACL don't cause a mismatch. '_purge': true (the default) requires the same entries in the same order and count as actual, but each entry pair is compared semantically: an aliased permission name (for example 'fullControl' versus the individual rights it implies) or a differently ordered 'flags' list that is otherwise equivalent doesn't cause a mismatch.", "default": true } }, @@ -246,7 +246,7 @@ "accessMask": { "type": "array", "title": "Access mask", - "description": "The permissions granted, denied, or audited by this entry.", + "description": "The permissions granted, denied, or audited by this entry. The 'generic*' values represent the raw GENERIC_* access mask bits; Windows maps them to the equivalent file-specific rights (for example 'genericAll' to 'fullControl') and they're accepted so that an existing entry using them can always be read back and represented, but there's normally no reason to specify them in a request.", "minItems": 1, "items": { "type": "string", @@ -269,7 +269,11 @@ "write", "readAndExecute", "modify", - "fullControl" + "fullControl", + "genericRead", + "genericWrite", + "genericExecute", + "genericAll" ] } }, diff --git a/resources/file_dsc/src/acl.rs b/resources/file_dsc/src/acl.rs index da9223858..aded278e0 100644 --- a/resources/file_dsc/src/acl.rs +++ b/resources/file_dsc/src/acl.rs @@ -33,13 +33,16 @@ pub fn set(input: &FileAccessControlList) -> Result Result { .ok() .map_err(|error| t!("acl.getError", path = path, error = error.to_string()).to_string())?; - read_access_control_list(&mut buffer, want_discretionary, want_system).map_err(acl_error) + // SAFETY: buffer was just populated in-place by a successful GetFileSecurityW call above, + // sized exactly to the `required_length` that same API reported; it is therefore a + // complete, well-formed self-relative security descriptor. + unsafe { read_access_control_list(&mut buffer, want_discretionary, want_system) } + .map_err(acl_error) } /// Writes `security_information`'s sections (DACL and/or SACL) to `path`, including their diff --git a/resources/file_dsc/src/types.rs b/resources/file_dsc/src/types.rs index 920c009af..57b4541a0 100644 --- a/resources/file_dsc/src/types.rs +++ b/resources/file_dsc/src/types.rs @@ -78,6 +78,19 @@ pub enum FileAccessRight { Modify, /// `FILE_ALL_ACCESS`: a composite of every standard and file-specific right. FullControl, + /// `GENERIC_READ`: the generic read right. Windows maps this to [`Read`](Self::Read) for + /// file and folder objects, but the raw bit can still appear in an existing access control + /// entry's mask, so it must be representable rather than silently discarded when read back. + GenericRead, + /// `GENERIC_WRITE`: the generic write right, mapped by Windows to + /// [`Write`](Self::Write) for file and folder objects. + GenericWrite, + /// `GENERIC_EXECUTE`: the generic execute right, mapped by Windows to + /// [`ExecuteFile`](Self::ExecuteFile)-equivalent access for file and folder objects. + GenericExecute, + /// `GENERIC_ALL`: the generic all-access right, mapped by Windows to + /// [`FullControl`](Self::FullControl) for file and folder objects. + GenericAll, } impl AccessMask for FileAccessRight { @@ -102,6 +115,10 @@ impl AccessMask for FileAccessRight { Self::ReadAndExecute => 0x0012_00A9, Self::Modify => 0x0013_01BF, Self::FullControl => 0x001F_01FF, + Self::GenericExecute => 0x2000_0000, + Self::GenericWrite => 0x4000_0000, + Self::GenericRead => 0x8000_0000, + Self::GenericAll => 0x1000_0000, } } @@ -126,6 +143,10 @@ impl AccessMask for FileAccessRight { Self::ReadAndExecute, Self::Modify, Self::FullControl, + Self::GenericRead, + Self::GenericWrite, + Self::GenericExecute, + Self::GenericAll, ] } } diff --git a/resources/file_dsc/tests/filesystem_acl.tests.ps1 b/resources/file_dsc/tests/filesystem_acl.tests.ps1 index 57edaf272..3cd0d65af 100644 --- a/resources/file_dsc/tests/filesystem_acl.tests.ps1 +++ b/resources/file_dsc/tests/filesystem_acl.tests.ps1 @@ -276,8 +276,81 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeFalse } + + It 'Tests true with _purge true using an alias-equivalent access mask' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'alias.txt') | + Select-Object -ExpandProperty FullName + # Individually, these rights are bit-for-bit identical to `fullControl`. + $decomposedMask = @( + 'readData', 'writeData', 'appendData', 'readExtendedAttributes', + 'writeExtendedAttributes', 'executeFile', 'deleteChild', 'readAttributes', + 'writeAttributes', 'delete', 'readPermissions', 'changePermissions', + 'takeOwnership', 'synchronize' + ) + $acl = @{ + discretionary = @( + @{ principal = $currentSid; type = 'allow'; accessMask = $decomposedMask } + ) + discretionaryProtected = $false + } + $json = @{ path = $path; accessControlList = $acl } | ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + # A real system read always reports the most comprehensive alias that fits exactly, + # so the actual entry now reports `fullControl`; a Test using the original, + # decomposed list must still report satisfied under `_purge: true` (the default) + # instead of comparing the access mask arrays for raw equality. + $testOutput = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeTrue + } + + It 'Defaults discretionaryProtected to false when purging and the property is omitted' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'purgeprotected.txt') | + Select-Object -ExpandProperty FullName + $protectedJson = @{ path = $path; accessControlList = (New-DesiredAcl -Protected $true) } | + ConvertTo-Json -Depth 10 -Compress + $null = $protectedJson | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + # Purge (the default) again, without specifying discretionaryProtected this time. + $unspecifiedAcl = @{ discretionary = (New-DesiredAcl).discretionary } + $unspecifiedJson = @{ path = $path; accessControlList = $unspecifiedAcl } | + ConvertTo-Json -Depth 10 -Compress + $setOutput = $unspecifiedJson | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $setState = ($setOutput | ConvertFrom-Json).afterState + + # Omitting the property during a purge write resets protection to false; it is not + # left unchanged from the previously protected state. + $setState.accessControlList.discretionaryProtected | Should -BeFalse + } + + It 'Accepts a generic access mask value without rejecting or silently dropping it' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'generic.txt') | + Select-Object -ExpandProperty FullName + $acl = @{ + discretionary = @( + @{ principal = $currentSid; type = 'allow'; accessMask = @('genericAll') } + ) + } + $json = @{ path = $path; accessControlList = $acl } | ConvertTo-Json -Depth 10 -Compress + $setOutput = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $setState = ($setOutput | ConvertFrom-Json).afterState + + # NTFS maps `GENERIC_ALL` to the file-specific `fullControl`-equivalent rights when a + # security descriptor is actually applied to a real file, so the mask read back + # reports the mapped alias instead of the original raw generic bit. The important + # thing this test guards is that the request is accepted at all (not rejected as an + # unrepresentable access mask) and that the resulting access is the expected, full + # equivalent -- not that the literal `genericAll` value survives the round trip. + $setState.accessControlList.discretionary[0].accessMask | Should -Contain 'fullControl' + } } + It 'Leaves the system access control list unread when it is not requested' { $path = New-Item -ItemType File -Path (Join-Path $testRoot 'partial.txt') | Select-Object -ExpandProperty FullName diff --git a/resources/registry/locales/en-us.toml b/resources/registry/locales/en-us.toml index 929d81dd2..a37fdea14 100644 --- a/resources/registry/locales/en-us.toml +++ b/resources/registry/locales/en-us.toml @@ -30,6 +30,7 @@ configArgsInputHelp = "The registry JSON input." configArgsWhatIfHelp = "Run as a what-if operation instead of applying the registry configuration." configGetAbout = "Retrieve registry configuration." configSetAbout = "Apply registry configuration." +configTestAbout = "Test registry configuration." configDeleteAbout = "Delete registry configuration." queryAbout = "Query a registry key or value." queryArgsKeyPathHelp = "The registry key path to query." diff --git a/resources/registry/registry.dsc.manifests.json b/resources/registry/registry.dsc.manifests.json index 7b4bf5ef0..184ea2895 100644 --- a/resources/registry/registry.dsc.manifests.json +++ b/resources/registry/registry.dsc.manifests.json @@ -34,6 +34,18 @@ ], "whatIfReturns": "state" }, + "test": { + "executable": "registry", + "args": [ + "config", + "test", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "return": "state" + }, "delete": { "executable": "registry", "args": [ @@ -101,6 +113,19 @@ "whatIfReturns": "state", "handlesExist": true }, + "test": { + "executable": "registry", + "args": [ + "config", + "test", + "--list", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "return": "state" + }, "exitCodes": { "0": "Success", "1": "Invalid parameter", diff --git a/resources/registry/src/args.rs b/resources/registry/src/args.rs index 876df2897..9c6500746 100644 --- a/resources/registry/src/args.rs +++ b/resources/registry/src/args.rs @@ -57,6 +57,13 @@ pub enum ConfigSubCommand { #[clap(short = 'w', long, help = t!("args.configArgsWhatIfHelp").to_string())] what_if: bool, }, + #[clap(name = "test", about = t!("args.configTestAbout").to_string())] + Test { + #[clap(short, long, required = true, help = t!("args.configArgsInputHelp").to_string())] + input: String, + #[clap(short, long, hide = true)] + list: bool, + }, #[clap(name = "delete", about = t!("args.configDeleteAbout").to_string())] Delete { #[clap(short, long, required = true, help = t!("args.configArgsInputHelp").to_string())] diff --git a/resources/registry/src/main.rs b/resources/registry/src/main.rs index e9ef6008b..4d18eea2d 100644 --- a/resources/registry/src/main.rs +++ b/resources/registry/src/main.rs @@ -84,7 +84,7 @@ fn main() -> ExitCode { match subcommand { ConfigSubCommand::Get{input, list} => { trace!("Get input: {input}"); - let mut output = RegistryList { registry_entries: vec![], registry_file_path: None }; + let mut output = RegistryList { registry_entries: vec![], registry_file_path: None, in_desired_state: None }; let Ok(reg_list) = import_input(&input, list) else { return ExitCode::from(EXIT_INVALID_INPUT); }; @@ -118,7 +118,7 @@ fn main() -> ExitCode { }, ConfigSubCommand::Set{input, list, what_if} => { trace!("Set input: {input}, what_if: {what_if}"); - let mut output = RegistryList { registry_entries: vec![], registry_file_path: None }; + let mut output = RegistryList { registry_entries: vec![], registry_file_path: None, in_desired_state: None }; let Ok(reg_list) = import_input(&input, list) else { return ExitCode::from(EXIT_INVALID_INPUT); }; @@ -179,6 +179,47 @@ fn main() -> ExitCode { } return ExitCode::from(EXIT_SUCCESS); }, + ConfigSubCommand::Test{input, list} => { + trace!("Test input: {input}"); + let mut output = RegistryList { + registry_entries: vec![], + registry_file_path: None, + in_desired_state: Some(true), + }; + let Ok(reg_list) = import_input(&input, list) else { + return ExitCode::from(EXIT_INVALID_INPUT); + }; + for reg in reg_list.registry_entries { + let reg_helper = match RegistryHelper::new_from_registry(®) { + Ok(helper) => helper, + Err(err) => { + error!("{err}"); + return ExitCode::from(EXIT_INVALID_INPUT); + } + }; + match reg_helper.test() { + Ok(reg_result) => { + if reg_result.in_desired_state == Some(false) { + output.in_desired_state = Some(false); + } + if list { + output.registry_entries.push(reg_result); + } else { + let json = serde_json::to_string(®_result).unwrap(); + println!("{json}"); + return ExitCode::from(EXIT_SUCCESS); + } + }, + Err(err) => { + error!("{err}"); + return ExitCode::from(EXIT_REGISTRY_ERROR); + } + } + } + let json = serde_json::to_string(&output).unwrap(); + println!("{json}"); + return ExitCode::from(EXIT_SUCCESS); + }, ConfigSubCommand::Delete{input, what_if} => { trace!("Delete input: {input}, what_if: {what_if}"); let mut reg_helper = match RegistryHelper::new_from_json(&input) { @@ -238,7 +279,7 @@ fn import_input(input: &str, list: bool) -> Result { } } else { match serde_json::from_str::(input) { - Ok(reg) => Ok(RegistryList { registry_entries: vec![reg], registry_file_path: None }), + Ok(reg) => Ok(RegistryList { registry_entries: vec![reg], registry_file_path: None, in_desired_state: None }), Err(err) => { error!("{err}"); Err(ExitCode::from(EXIT_INVALID_INPUT)) diff --git a/resources/registry/src/types.rs b/resources/registry/src/types.rs index 82529c227..2e671206b 100644 --- a/resources/registry/src/types.rs +++ b/resources/registry/src/types.rs @@ -13,4 +13,8 @@ pub struct RegistryList { /// are performed against the offline hive instead of the live system registry. #[serde(rename = "registryFilePath", skip_serializing_if = "Option::is_none")] pub registry_file_path: Option, + /// Whether every entry in `registryEntries` is in the desired state. Only returned by a + /// `test` operation. + #[serde(rename = "_inDesiredState", skip_serializing_if = "Option::is_none")] + pub in_desired_state: Option, } diff --git a/resources/registry/tests/registry.config.set.tests.ps1 b/resources/registry/tests/registry.config.set.tests.ps1 index 3d826a066..7b32a2452 100644 --- a/resources/registry/tests/registry.config.set.tests.ps1 +++ b/resources/registry/tests/registry.config.set.tests.ps1 @@ -305,6 +305,75 @@ Describe 'registry config set tests' { $result.accessControlList.discretionary[0].type | Should -BeExactly 'deny' } + It 'Rejects an access control list request that specifies neither discretionary nor system' -Skip:(!$IsWindows) { + $json = @{ + keyPath = 'HKCU\1\AclEmptySet' + accessControlList = @{} + } | ConvertTo-Json -Depth 10 -Compress + + registry config set --input $json 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Not -Be 0 + (Get-Content -Path $TestDrive/error.log -Raw) | Should -Match 'Registry ACL' + Get-Item -Path 'HKCU:\1\AclEmptySet' -ErrorAction Ignore | Should -BeNullOrEmpty + } + + It 'Rejects the removed wow6464Key/wow6432Key access mask values' -Skip:(!$IsWindows) { + $json = @{ + keyPath = 'HKCU\1\AclWow64Set' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('wow6464Key') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + + registry config set --input $json 2>$TestDrive/error.log + + # `wow6464Key`/`wow6432Key` are registry view selectors, not ACE permissions, and are no + # longer valid accessMask values. + $LASTEXITCODE | Should -Not -Be 0 + Get-Item -Path 'HKCU:\1\AclWow64Set' -ErrorAction Ignore | Should -BeNullOrEmpty + } + + It 'Defaults discretionaryProtected to false when purging and the property is omitted' -Skip:(!$IsWindows) { + New-Item -Path 'HKCU:\1\AclPurgeProtectedDefault' -Force | Out-Null + $protectedRequest = @{ + keyPath = 'HKCU\1\AclPurgeProtectedDefault' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + ) + discretionaryProtected = $true + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $protectedRequest 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # Purge (the default) again, this time without specifying discretionaryProtected. + $unspecifiedRequest = @{ + keyPath = 'HKCU\1\AclPurgeProtectedDefault' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $unspecifiedRequest 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $getJson = @{ + keyPath = 'HKCU\1\AclPurgeProtectedDefault' + accessControlList = @{ discretionary = @() } + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config get --input $getJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # Omitting the property during a purge write resets protection to false; it is not left + # unchanged from the previously protected state. + $result.accessControlList.discretionaryProtected | Should -BeFalse + } + It 'Should succeed when _exist is false and value does not exist' -Skip:(!$IsWindows) { $config = @{ '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' @@ -401,4 +470,42 @@ Describe 'registry config set tests' { $result._exist | Should -Be $false $result.valueData | Should -BeNullOrEmpty } + + It 'Can whatif-set a system ACL on an existing key without a privilege error' -Skip:(!$IsWindows) { + # Requesting a `system` section means the resource must open the key with + # ACCESS_SYSTEM_SECURITY -- including during the `can_set` check that a what-if preview + # performs -- which itself requires the SeSecurityPrivilege privilege to already be + # enabled. If the privilege weren't enabled before that check, this would fail with a + # privilege error instead of returning a preview, even for a user who otherwise holds the + # privilege (for example an Administrator who hasn't elevated). + $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + $isElevated = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + if (-not $isElevated) { + Set-ItResult -Skipped -Because 'Test requires elevated privileges' + return + } + + New-Item -Path 'HKCU:\1\AclSaclWhatIf' -Force | Out-Null + $json = @{ + keyPath = 'HKCU\1\AclSaclWhatIf' + accessControlList = @{ + system = @( + @{ + principal = $env:USERNAME + type = 'audit' + accessMask = @('read') + auditFlags = @('success', 'failure') + } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + + $output = registry config set -w --input $json 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + (Get-Content -Path $TestDrive/error.log -Raw) | Should -Not -Match 'SeSecurityPrivilege' + + $result = $output | ConvertFrom-Json + $result._metadata.whatIf | Should -Contain "Would set ACL on key 'HKCU\1\AclSaclWhatIf'" + } } diff --git a/resources/registry/tests/registry.config.test.tests.ps1 b/resources/registry/tests/registry.config.test.tests.ps1 new file mode 100644 index 000000000..0a16c2862 --- /dev/null +++ b/resources/registry/tests/registry.config.test.tests.ps1 @@ -0,0 +1,151 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'registry config test tests' { + AfterEach { + if ($IsWindows) { + Remove-Item -Path 'HKCU:\1' -Recurse -ErrorAction Ignore + } + } + + It 'Reports in desired state when the value matches' -Skip:(!$IsWindows) { + $json = @{ + keyPath = 'HKCU\1\TestValue' + valueName = 'Hello' + valueData = @{ String = 'World' } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $json 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $result = registry config test --input $json 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $result._inDesiredState | Should -BeTrue + } + + It 'Reports not in desired state when the value differs' -Skip:(!$IsWindows) { + $setJson = @{ + keyPath = 'HKCU\1\TestValueMismatch' + valueName = 'Hello' + valueData = @{ String = 'World' } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $setJson 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $testJson = @{ + keyPath = 'HKCU\1\TestValueMismatch' + valueName = 'Hello' + valueData = @{ String = 'Different' } + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config test --input $testJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $result._inDesiredState | Should -BeFalse + } + + It 'Reports not in desired state when the key does not exist' -Skip:(!$IsWindows) { + $json = @{ + keyPath = 'HKCU\1\TestDoesNotExist' + } | ConvertTo-Json -Depth 10 -Compress + + $result = registry config test --input $json 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $result._inDesiredState | Should -BeFalse + } + + It 'Reports in desired state for an alias-equivalent access control list under _purge:true' -Skip:(!$IsWindows) { + New-Item -Path 'HKCU:\1\TestAclAlias' -Force | Out-Null + + # Individually, these rights are bit-for-bit identical to `fullControl`. + $decomposedMask = @( + 'queryValue', 'setValue', 'createSubKey', 'enumerateSubKeys', 'notify', + 'createLink', 'delete', 'readPermissions', 'changePermissions', 'takeOwnership' + ) + $json = @{ + keyPath = 'HKCU\1\TestAclAlias' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = $decomposedMask } + ) + discretionaryProtected = $false + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $json 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # A real system read always reports the most comprehensive alias that fits exactly, so + # the actual entry now reports `fullControl`; a Test using the original, decomposed list + # must still report satisfied under `_purge: true` (the default) instead of comparing the + # access mask arrays for raw equality. + $result = registry config test --input $json 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $result._inDesiredState | Should -BeTrue + } + + It 'Reports in desired state with _purge:false when the requested ACE already exists' -Skip:(!$IsWindows) { + New-Item -Path 'HKCU:\1\TestAclMerge' -Force | Out-Null + $setJson = @{ + keyPath = 'HKCU\1\TestAclMerge' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $setJson 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $testJson = @{ + keyPath = 'HKCU\1\TestAclMerge' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + ) + _purge = $false + } + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config test --input $testJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $result._inDesiredState | Should -BeTrue + } + + It 'Rejects an access control list request that specifies neither discretionary nor system' -Skip:(!$IsWindows) { + $json = @{ + keyPath = 'HKCU\1\TestAclEmpty' + accessControlList = @{} + } | ConvertTo-Json -Depth 10 -Compress + + registry config test --input $json 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Not -Be 0 + (Get-Content -Path $TestDrive/error.log -Raw) | Should -Match 'Registry ACL' + } + + It 'Can test a registry list and aggregates the in-desired-state result' -Skip:(!$IsWindows) { + $setJson = @{ + registryEntries = @( + @{ keyPath = 'HKCU\1\TestListA'; valueName = 'Hello'; valueData = @{ String = 'World' } } + @{ keyPath = 'HKCU\1\TestListB'; valueName = 'Hello'; valueData = @{ String = 'World' } } + ) + } | ConvertTo-Json -Depth 10 -Compress + registry config set --list --input $setJson 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $matchingTestJson = $setJson + $result = registry config test --list --input $matchingTestJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $result._inDesiredState | Should -BeTrue + $result.registryEntries[0]._inDesiredState | Should -BeTrue + $result.registryEntries[1]._inDesiredState | Should -BeTrue + + $mismatchTestJson = @{ + registryEntries = @( + @{ keyPath = 'HKCU\1\TestListA'; valueName = 'Hello'; valueData = @{ String = 'World' } } + @{ keyPath = 'HKCU\1\TestListB'; valueName = 'Hello'; valueData = @{ String = 'Different' } } + ) + } | ConvertTo-Json -Depth 10 -Compress + $mismatchResult = registry config test --list --input $mismatchTestJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $mismatchResult._inDesiredState | Should -BeFalse + $mismatchResult.registryEntries[0]._inDesiredState | Should -BeTrue + $mismatchResult.registryEntries[1]._inDesiredState | Should -BeFalse + } +} diff --git a/resources/registry/tests/registry.config.whatif.tests.ps1 b/resources/registry/tests/registry.config.whatif.tests.ps1 index 3747224f8..e0de4208a 100644 --- a/resources/registry/tests/registry.config.whatif.tests.ps1 +++ b/resources/registry/tests/registry.config.whatif.tests.ps1 @@ -108,6 +108,71 @@ Describe 'registry config whatif tests' { Should -BeExactly ($before.discretionary | ConvertTo-Json -Depth 10 -Compress) } + It 'Can whatif setting a value and merging an ACL on an existing key, returning the effective merged ACL' -Skip:(!$IsWindows) { + New-Item -Path 'HKCU:\1\AclWhatIfValueName' -Force | Out-Null + $initial = @{ + keyPath = 'HKCU\1\AclWhatIfValueName' + accessControlList = @{ + discretionary = @( + @{ principal = $env:USERNAME; type = 'allow'; accessMask = @('fullControl') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + registry config set --input $initial 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # The current user's SID, resolved so it can be compared against the merged output. + $currentUserSid = ([Security.Principal.NTAccount]::new($env:USERNAME)).Translate( + [Security.Principal.SecurityIdentifier]).Value + $anonymousSid = 'S-1-5-7' + + $whatIfJson = @{ + keyPath = 'HKCU\1\AclWhatIfValueName' + valueName = 'Hello' + valueData = @{ String = 'World' } + accessControlList = @{ + discretionary = @( + @{ principal = $anonymousSid; type = 'deny'; accessMask = @('read') } + ) + _purge = $false + } + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config set -w --input $whatIfJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # Since the key already exists, the what-if preview must compute and return the same + # effective, normalized/merged ACL that an actual Set would apply -- both the + # pre-existing entry and the newly merged one -- not the raw, unmerged request. + $principals = $result.accessControlList.discretionary.principal + $principals | Should -Contain $currentUserSid + $principals | Should -Contain $anonymousSid + + # What-if must not have actually written the value or changed the ACL. + $afterGet = registry config get --input $initial 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $afterGet.accessControlList.discretionary.Count | Should -Be 1 + } + + It 'Can whatif setting a value and ACL on a missing key, returning the raw (unmerged) request' -Skip:(!$IsWindows) { + $whatIfJson = @{ + keyPath = 'HKCU\1\AclWhatIfMissingKey' + valueName = 'Hello' + valueData = @{ String = 'World' } + accessControlList = @{ + discretionary = @( + @{ principal = 'S-1-5-7'; type = 'deny'; accessMask = @('read') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + $result = registry config set -w --input $whatIfJson 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + # The key doesn't exist, so the preview falls back to reporting the raw request rather + # than attempting (and failing) to read a nonexistent key's current ACL. + $result.accessControlList.discretionary.Count | Should -Be 1 + $result.accessControlList.discretionary[0].principal | Should -BeExactly 'S-1-5-7' + } + It 'Can whatif an existing deeply nested key and value' -Skip:(!$IsWindows) { $set_json = @' { From 218f8b0d6e8150c67888eb278c5bb2fc020211dd Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 17 Oct 2026 12:35:21 -0700 Subject: [PATCH 3/9] Fix ACL preflight and comparison semantics Validate ACL operations before registry mutation, represent absent SACLs as empty audit lists, map generic rights for semantic comparison, and align the filesystem schema contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- lib/dsc-lib-registry/src/acl.rs | 10 +++++ lib/dsc-lib-registry/src/config.rs | 15 +++++++ lib/dsc-lib-registry/src/lib.rs | 2 +- lib/lib-acl/src/error.rs | 7 +-- lib/lib-acl/src/model.rs | 45 +++++++++++++++++-- lib/lib-acl/src/win32/security_descriptor.rs | 30 ++++++++----- .../file_dsc/file_dsc.dsc.manifests.json | 3 +- resources/file_dsc/src/types.rs | 15 +++++++ .../file_dsc/tests/filesystem_acl.tests.ps1 | 17 +++++++ .../tests/registry.config.set.tests.ps1 | 22 +++++++++ 10 files changed, 146 insertions(+), 20 deletions(-) diff --git a/lib/dsc-lib-registry/src/acl.rs b/lib/dsc-lib-registry/src/acl.rs index e90e24405..7f9c3f774 100644 --- a/lib/dsc-lib-registry/src/acl.rs +++ b/lib/dsc-lib-registry/src/acl.rs @@ -65,6 +65,16 @@ pub fn validate(requested: &RegistryAccessControlList) -> Result<(), RegistryErr requested.validate().map_err(acl_error) } +/// Validates an ACL request, resolves every principal, and enables any privilege required to +/// access its sections. Call this before a larger operation begins mutating registry keys or +/// values so ACL-specific failures cannot leave unrelated registry data partially updated. +pub fn preflight(requested: &RegistryAccessControlList) -> Result<(), RegistryError> { + validate(requested)?; + ensure_privilege_for(requested)?; + normalize(requested).map_err(acl_error)?; + Ok(()) +} + /// Enables the `SeSecurityPrivilege` privilege when `requested` manages the system access /// control list (SACL), since reading or writing a SACL -- or merely opening a key with the /// `ACCESS_SYSTEM_SECURITY` right, as [`can_set`] does to check whether a key can be written to diff --git a/lib/dsc-lib-registry/src/config.rs b/lib/dsc-lib-registry/src/config.rs index 713c8219a..c0150b3f9 100644 --- a/lib/dsc-lib-registry/src/config.rs +++ b/lib/dsc-lib-registry/src/config.rs @@ -106,6 +106,21 @@ impl AccessMask for RegistryAccessRight { Self::GenericAll, ] } + + fn map_generic_bits(bits: u32) -> u32 { + let mut mapped = bits; + for (generic, specific) in [ + (0x1000_0000, Self::FullControl.bits()), + (0x2000_0000, Self::Execute.bits()), + (0x4000_0000, Self::Write.bits()), + (0x8000_0000, Self::Read.bits()), + ] { + if mapped & generic != 0 { + mapped = (mapped & !generic) | specific; + } + } + mapped + } } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] diff --git a/lib/dsc-lib-registry/src/lib.rs b/lib/dsc-lib-registry/src/lib.rs index 5078aa99d..37547a455 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -265,7 +265,7 @@ impl RegistryHelper { return self.set_offline(); } if let Some(acl) = &self.config.access_control_list { - acl::validate(acl)?; + acl::preflight(acl)?; } let mut what_if_metadata: Vec = Vec::new(); diff --git a/lib/lib-acl/src/error.rs b/lib/lib-acl/src/error.rs index 5e2033838..2610b9acc 100644 --- a/lib/lib-acl/src/error.rs +++ b/lib/lib-acl/src/error.rs @@ -50,11 +50,8 @@ pub enum AclError { )] UnrepresentableAccessMask(u32), - /// The discretionary or system access control list is NULL (absent), which Windows treats - /// as granting unrestricted access (for a DACL) or performing no auditing (for a SACL). This - /// is distinct from a present-but-empty access control list, which denies all access by - /// default; the two are never conflated, to avoid reporting (or comparing against) a more - /// permissive state as if it were the safe, empty one. + /// The discretionary access control list is NULL (absent), which grants unrestricted access. + /// This is distinct from a present-but-empty DACL, which denies all access by default. #[error( "The {0} access control list is NULL (absent), which Windows treats as unrestricted; \ this is not supported as equivalent to an empty access control list" diff --git a/lib/lib-acl/src/model.rs b/lib/lib-acl/src/model.rs index 4a2fec2ff..25d03e704 100644 --- a/lib/lib-acl/src/model.rs +++ b/lib/lib-acl/src/model.rs @@ -23,6 +23,13 @@ pub trait AccessMask: Sized + Copy + Clone + Eq + 'static { /// All known values, used to decompose a raw access mask back into a list of flags. fn all() -> &'static [Self]; + + /// Maps generic access rights in `bits` to the resource-specific rights Windows uses when + /// evaluating access. The default preserves every bit for resource types without a generic + /// mapping. + fn map_generic_bits(bits: u32) -> u32 { + bits + } } /// Combines a list of access mask values into a single raw bitmask. @@ -31,6 +38,10 @@ pub fn mask_to_bits(values: &[P]) -> u32 { values.iter().fold(0_u32, |mask, value| mask | value.bits()) } +fn effective_mask_bits(values: &[P]) -> u32 { + P::map_generic_bits(mask_to_bits(values)) +} + /// Decomposes a raw bitmask into a list of access mask values. Candidates with more bits are /// preferred so that shorthand aliases (for example `FullControl`) are chosen over a long list /// of individual atomic rights when they fit exactly. @@ -318,7 +329,7 @@ fn semantic_key(entry: &AccessControlEntry

) -> AceSemanticKey AceSemanticKey { principal: entry.principal.clone(), ace_type: entry.ace_type, - mask_bits: mask_to_bits(&entry.access_mask), + mask_bits: effective_mask_bits(&entry.access_mask), flags: normalized_flags(&entry.flags), audit_flags: normalized_audit_flags(&entry.audit_flags), } @@ -335,7 +346,7 @@ fn entries_semantically_equal( ) -> bool { a.principal == b.principal && a.ace_type == b.ace_type - && mask_to_bits(&a.access_mask) == mask_to_bits(&b.access_mask) + && effective_mask_bits(&a.access_mask) == effective_mask_bits(&b.access_mask) && normalized_flags(&a.flags) == normalized_flags(&b.flags) && normalized_audit_flags(&a.audit_flags) == normalized_audit_flags(&b.audit_flags) } @@ -532,6 +543,7 @@ mod tests { Read, Write, FullControl, + GenericAll, } impl AccessMask for TestRight { @@ -542,11 +554,20 @@ mod tests { // Aliases the combination of Read and Write, the same way `fullControl` aliases // several atomic rights for real resources. Self::FullControl => 0x3, + Self::GenericAll => 0x100, } } fn all() -> &'static [Self] { - &[Self::Read, Self::Write, Self::FullControl] + &[Self::Read, Self::Write, Self::FullControl, Self::GenericAll] + } + + fn map_generic_bits(bits: u32) -> u32 { + if bits & Self::GenericAll.bits() == 0 { + bits + } else { + (bits & !Self::GenericAll.bits()) | Self::FullControl.bits() + } } } @@ -643,6 +664,24 @@ mod tests { assert_eq!(merged.len(), 1); } + #[test] + fn merge_drops_duplicates_with_equivalent_generic_access_masks() { + let current = vec![entry( + "S-1-1-0", + AceType::Allow, + vec![TestRight::FullControl], + )]; + let requested = vec![entry( + "S-1-1-0", + AceType::Allow, + vec![TestRight::GenericAll], + )]; + + let merged = merge_entries(¤t, &requested); + + assert_eq!(merged.len(), 1); + } + #[test] fn merge_keeps_explicit_and_inherited_entries_distinct() { let mut inherited = entry("S-1-1-0", AceType::Allow, vec![TestRight::Read]); diff --git a/lib/lib-acl/src/win32/security_descriptor.rs b/lib/lib-acl/src/win32/security_descriptor.rs index f73ea69b5..8c4f30936 100644 --- a/lib/lib-acl/src/win32/security_descriptor.rs +++ b/lib/lib-acl/src/win32/security_descriptor.rs @@ -58,8 +58,7 @@ pub fn read_security_information( /// # Errors /// /// Returns [`AclError`] when the descriptor is malformed or contains an unsupported ACE type, or -/// [`AclError::NullAcl`] when a requested section's underlying access control list is NULL -/// (absent) rather than present-but-empty. +/// [`AclError::NullAcl`] when a requested DACL is NULL (absent) rather than present-but-empty. pub unsafe fn read_access_control_list( descriptor_bytes: &mut [u8], want_discretionary: bool, @@ -133,15 +132,14 @@ unsafe fn read_acl_section( code: error.code().0 as u32, })?; - // A NULL access control list (the list is marked present, but the pointer to it is NULL -- - // or, more rarely, not marked present at all) means Windows applies no discretionary - // protection (DACL) or no auditing (SACL): a DACL-less object grants unrestricted access to - // everyone. This is a materially different, far more permissive state than a present but - // empty list (which denies all access by default), so it must never be reported as - // equivalent to one; doing so could make a desired-state comparison silently report a - // dangerously open object as satisfied by an empty-list request. + // A NULL DACL grants unrestricted access, unlike a present but empty DACL, which denies all + // access. A NULL SACL and an empty SACL both mean no auditing, so the portable model can + // safely represent an absent SACL as an empty system list. if !present.as_bool() || acl_ptr.is_null() { - return Err(AclError::NullAcl(if sacl { "system" } else { "discretionary" })); + if sacl { + return Ok((Vec::new(), control & SE_SACL_PROTECTED.0 != 0)); + } + return Err(AclError::NullAcl("discretionary")); } // SAFETY: the caller guarantees descriptor (and therefore acl_ptr, which GetSecurityDescriptorDacl/ @@ -404,4 +402,16 @@ mod tests { assert_eq!(result.discretionary, Some(Vec::new())); } + + #[test] + fn read_access_control_list_treats_absent_sacl_as_empty() { + let mut bytes = build_descriptor_with_dacl(Some(&[])); + + // SAFETY: bytes was just built above as a well-formed self-relative descriptor. + let result = unsafe { read_access_control_list::(&mut bytes, false, true) } + .expect("an absent SACL means no auditing and must be represented as an empty list"); + + assert_eq!(result.system, Some(Vec::new())); + assert_eq!(result.system_protected, Some(false)); + } } diff --git a/resources/file_dsc/file_dsc.dsc.manifests.json b/resources/file_dsc/file_dsc.dsc.manifests.json index dad19352d..22c5962b6 100644 --- a/resources/file_dsc/file_dsc.dsc.manifests.json +++ b/resources/file_dsc/file_dsc.dsc.manifests.json @@ -57,7 +57,8 @@ "description": "Manage the UTF-8 content of a local file.", "type": "object", "required": [ - "path" + "path", + "accessControlList" ], "properties": { "content": { diff --git a/resources/file_dsc/src/types.rs b/resources/file_dsc/src/types.rs index 57b4541a0..5ebc2d6b4 100644 --- a/resources/file_dsc/src/types.rs +++ b/resources/file_dsc/src/types.rs @@ -149,6 +149,21 @@ impl AccessMask for FileAccessRight { Self::GenericAll, ] } + + fn map_generic_bits(bits: u32) -> u32 { + let mut mapped = bits; + for (generic, specific) in [ + (0x1000_0000, Self::FullControl.bits()), + (0x2000_0000, 0x0012_00A0), + (0x4000_0000, Self::Write.bits()), + (0x8000_0000, Self::Read.bits()), + ] { + if mapped & generic != 0 { + mapped = (mapped & !generic) | specific; + } + } + mapped + } } /// The discretionary and/or system access control list of a local file or folder. diff --git a/resources/file_dsc/tests/filesystem_acl.tests.ps1 b/resources/file_dsc/tests/filesystem_acl.tests.ps1 index 3cd0d65af..61d654ed0 100644 --- a/resources/file_dsc/tests/filesystem_acl.tests.ps1 +++ b/resources/file_dsc/tests/filesystem_acl.tests.ps1 @@ -347,6 +347,19 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { # unrepresentable access mask) and that the resulting access is the expected, full # equivalent -- not that the literal `genericAll` value survives the round trip. $setState.accessControlList.discretionary[0].accessMask | Should -Contain 'fullControl' + + $testOutput = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeTrue + + $acl._purge = $false + $mergeJson = @{ path = $path; accessControlList = $acl } | + ConvertTo-Json -Depth 10 -Compress + $mergeOutput = $mergeJson | + dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + (($mergeOutput | ConvertFrom-Json).afterState.accessControlList.discretionary | + Where-Object principal -EQ $currentSid) | Should -HaveCount 1 } } @@ -425,6 +438,10 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { } $json = @{ path = $path; accessControlList = $acl } | ConvertTo-Json -Depth 10 -Compress + $testOutput = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeFalse + $setOutput = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) $setState = ($setOutput | ConvertFrom-Json).afterState diff --git a/resources/registry/tests/registry.config.set.tests.ps1 b/resources/registry/tests/registry.config.set.tests.ps1 index 7b32a2452..3f91a7d65 100644 --- a/resources/registry/tests/registry.config.set.tests.ps1 +++ b/resources/registry/tests/registry.config.set.tests.ps1 @@ -508,4 +508,26 @@ Describe 'registry config set tests' { $result = $output | ConvertFrom-Json $result._metadata.whatIf | Should -Contain "Would set ACL on key 'HKCU\1\AclSaclWhatIf'" } + + It 'Validates ACL principals before changing a registry value' -Skip:(!$IsWindows) { + $keyPath = 'HKCU:\1\AclPreflight' + $null = New-Item -Path $keyPath -Force + Set-ItemProperty -Path $keyPath -Name 'Data' -Value 'original' + $missingAccount = "DSC-Missing-$([guid]::NewGuid())" + $json = @{ + keyPath = 'HKCU\1\AclPreflight' + valueName = 'Data' + valueData = @{ String = 'changed' } + accessControlList = @{ + discretionary = @( + @{ principal = $missingAccount; type = 'allow'; accessMask = @('read') } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + + registry config set --input $json 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Not -Be 0 + (Get-ItemPropertyValue -Path $keyPath -Name 'Data') | Should -BeExactly 'original' + } } From f5a53aa489bcffa079d6c051977966c2ad57c45d Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 17 Oct 2026 12:55:35 -0700 Subject: [PATCH 4/9] Fix filesystem ACL schema requirement Require accessControlList on the ACL resource without invalidating File/Content instances. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- resources/file_dsc/file_dsc.dsc.manifests.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/file_dsc/file_dsc.dsc.manifests.json b/resources/file_dsc/file_dsc.dsc.manifests.json index 22c5962b6..2a8fac5da 100644 --- a/resources/file_dsc/file_dsc.dsc.manifests.json +++ b/resources/file_dsc/file_dsc.dsc.manifests.json @@ -57,8 +57,7 @@ "description": "Manage the UTF-8 content of a local file.", "type": "object", "required": [ - "path", - "accessControlList" + "path" ], "properties": { "content": { @@ -159,7 +158,8 @@ "description": "Manage the discretionary and/or system access control list of a local file or folder.", "type": "object", "required": [ - "path" + "path", + "accessControlList" ], "properties": { "path": { From 5453c74db65c5bbd0558837aedf8dfe221c2d881 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 17 Oct 2026 14:28:31 -0700 Subject: [PATCH 5/9] Add registry SACL regression coverage Round-trip registry audit ACLs and verify privilege activation from an isolated process with SeSecurityPrivilege initially disabled. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- lib/dsc-lib-registry/src/acl.rs | 91 ++++++++++++++++--- .../tests/registry.config.set.tests.ps1 | 42 +++++++++ 2 files changed, 122 insertions(+), 11 deletions(-) diff --git a/lib/dsc-lib-registry/src/acl.rs b/lib/dsc-lib-registry/src/acl.rs index 7f9c3f774..a0415478a 100644 --- a/lib/dsc-lib-registry/src/acl.rs +++ b/lib/dsc-lib-registry/src/acl.rs @@ -297,6 +297,13 @@ fn win32_error(operation: &str, code: u32) -> RegistryError { mod tests { use super::*; use lib_acl::{AccessControlEntry, AceType, AuditFlag}; + use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID}; + use windows::Win32::Security::{ + AdjustTokenPrivileges, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, + TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, TOKEN_QUERY, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + use windows::core::{HSTRING, PCWSTR}; fn entry(ace_type: AceType) -> AccessControlEntry { AccessControlEntry { @@ -461,20 +468,81 @@ mod tests { assert!(!is_satisfied(&requested, &actual).unwrap()); } + fn disable_security_privilege() { + let mut token = HANDLE::default(); + // SAFETY: the current-process pseudo-handle is valid and token is a writable local. + unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, + &mut token, + ) + } + .unwrap(); + + let name = HSTRING::from(lib_acl::win32::SE_SECURITY_NAME); + let mut luid = LUID::default(); + // SAFETY: name is a valid null-terminated string and luid is writable. + unsafe { LookupPrivilegeValueW(PCWSTR::null(), PCWSTR(name.as_ptr()), &mut luid) }.unwrap(); + let privileges = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: Default::default(), + }], + }; + // SAFETY: token is valid and privileges describes one privilege to disable. + unsafe { + AdjustTokenPrivileges( + token, + false, + Some(&raw const privileges), + 0, + None, + None, + ) + } + .unwrap(); + // SAFETY: token is owned by this helper. + unsafe { + let _ = CloseHandle(token); + } + } + + #[test] + fn can_set_enables_privilege_in_an_isolated_process() { + let module = module_path!() + .strip_prefix(concat!(env!("CARGO_CRATE_NAME"), "::")) + .unwrap_or(module_path!()); + let test_name = + format!("{module}::can_set_enables_privilege_in_an_isolated_process_child"); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--ignored", "--exact", &test_name]) + .env("DSC_ACL_PRIVILEGE_CHILD", "1") + .status() + .unwrap(); + + assert!(status.success()); + } + #[test] - fn can_set_enables_privilege_before_checking_a_missing_key_with_system_acl() { - // This behavior can only be exercised when the test process actually holds - // `SeSecurityPrivilege` (even if not yet enabled); skip gracefully otherwise, mirroring - // how other SACL-touching tests in this codebase are conditioned on elevation. + #[ignore = "launched by can_set_enables_privilege_in_an_isolated_process"] + fn can_set_enables_privilege_in_an_isolated_process_child() { + if std::env::var_os("DSC_ACL_PRIVILEGE_CHILD").is_none() { + return; + } if lib_acl::win32::enable_security_privilege().is_err() { return; } - // Requesting a `system` section means `can_set` must open the key with - // ACCESS_SYSTEM_SECURITY, which itself requires `SeSecurityPrivilege` to already be - // enabled; if `can_set` attempted to open the key before enabling the privilege, this - // would fail with a privilege error instead of gracefully reporting that the - // (non-existent) key can't be written to. + let key_path = format!( + r"HKCU\Software\DscAclPrivilegeTest-{}", + std::process::id() + ); + let helper = crate::RegistryHelper::new(&key_path, None, None).unwrap(); + helper.set().unwrap(); + disable_security_privilege(); + let mut audit_entry = entry(AceType::Audit); audit_entry.audit_flags = vec![AuditFlag::Success]; let requested = RegistryAccessControlList { @@ -482,8 +550,9 @@ mod tests { ..Default::default() }; - let can_set_result = can_set(r"HKCU\DoesNotExist\AclCanSetSystem", &requested); + let can_set_result = can_set(&key_path, &requested); + let _ = helper.remove(); - assert!(matches!(can_set_result, Ok(false)), "{can_set_result:?}"); + assert!(matches!(can_set_result, Ok(true)), "{can_set_result:?}"); } } diff --git a/resources/registry/tests/registry.config.set.tests.ps1 b/resources/registry/tests/registry.config.set.tests.ps1 index 3f91a7d65..f6affc526 100644 --- a/resources/registry/tests/registry.config.set.tests.ps1 +++ b/resources/registry/tests/registry.config.set.tests.ps1 @@ -509,6 +509,48 @@ Describe 'registry config set tests' { $result._metadata.whatIf | Should -Contain "Would set ACL on key 'HKCU\1\AclSaclWhatIf'" } + It 'Round-trips a system ACL without changing the discretionary ACL' -Skip:(!$IsWindows) { + $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Set-ItResult -Skipped -Because 'Test requires elevated privileges' + return + } + + $keyPath = 'HKCU:\1\AclSaclRoundTrip' + $null = New-Item -Path $keyPath -Force + $daclBefore = (Get-Acl -Path $keyPath).GetSecurityDescriptorSddlForm( + [Security.AccessControl.AccessControlSections]::Access) + $json = @{ + keyPath = 'HKCU\1\AclSaclRoundTrip' + accessControlList = @{ + system = @( + @{ + principal = $env:USERNAME + type = 'audit' + accessMask = @('read') + auditFlags = @('success', 'failure') + } + ) + systemProtected = $true + } + } | ConvertTo-Json -Depth 10 -Compress + + registry config set --input $json 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $actual = registry config get --input $json 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + $tested = registry config test --input $json 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + + $actual.accessControlList.systemProtected | Should -BeTrue + $actual.accessControlList.system[0].auditFlags | Should -Contain 'success' + $actual.accessControlList.system[0].auditFlags | Should -Contain 'failure' + $tested._inDesiredState | Should -BeTrue + $daclAfter = (Get-Acl -Path $keyPath).GetSecurityDescriptorSddlForm( + [Security.AccessControl.AccessControlSections]::Access) + $daclAfter | Should -BeExactly $daclBefore + } + It 'Validates ACL principals before changing a registry value' -Skip:(!$IsWindows) { $keyPath = 'HKCU:\1\AclPreflight' $null = New-Item -Path $keyPath -Force From c902310e2707c08ccf617c07022db8fcdc62e562 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 17 Oct 2026 15:46:28 -0700 Subject: [PATCH 6/9] Harden ACL principal and registry preparation Reject embedded NUL principals and prepare reusable merged security descriptors before registry value mutations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- lib/dsc-lib-registry/src/acl.rs | 50 ++++++++++++++++--- lib/dsc-lib-registry/src/lib.rs | 18 +++++-- lib/lib-acl/src/win32/sid.rs | 17 +++++++ .../file_dsc/tests/filesystem_acl.tests.ps1 | 24 +++++++++ .../tests/registry.config.set.tests.ps1 | 32 +++++++++++- 5 files changed, 128 insertions(+), 13 deletions(-) diff --git a/lib/dsc-lib-registry/src/acl.rs b/lib/dsc-lib-registry/src/acl.rs index a0415478a..bb30d7bd0 100644 --- a/lib/dsc-lib-registry/src/acl.rs +++ b/lib/dsc-lib-registry/src/acl.rs @@ -12,7 +12,7 @@ use lib_acl::merge_access_control_list; use windows::Win32::Foundation::{ ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, ERROR_PATH_NOT_FOUND, ERROR_SUCCESS, }; -use windows::Win32::Security::PSECURITY_DESCRIPTOR; +use windows::Win32::Security::{OBJECT_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR}; use windows::Win32::System::Registry::{ HKEY, HKEY_CLASSES_ROOT, HKEY_CURRENT_CONFIG, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, HKEY_USERS, REG_SAM_FLAGS, RegCloseKey, RegGetKeySecurity, RegOpenKeyExW, RegSetKeySecurity, @@ -34,6 +34,18 @@ const ACCESS_SYSTEM_SECURITY: REG_SAM_FLAGS = REG_SAM_FLAGS(0x0100_0000); type RegistryAccessControlList = AccessControlList; +pub(crate) struct PreparedAccessControlList { + effective: RegistryAccessControlList, + descriptor_bytes: Vec, + security_information: OBJECT_SECURITY_INFORMATION, +} + +impl PreparedAccessControlList { + pub(crate) fn effective(&self) -> &RegistryAccessControlList { + &self.effective + } +} + struct RegistryKey(HKEY); impl Drop for RegistryKey { @@ -168,14 +180,38 @@ pub fn set(path: &str, requested: &RegistryAccessControlList) -> Result<(), Regi validate(requested)?; ensure_privilege_for(requested)?; - let to_write = effective(path, requested)?; - let (mut descriptor_bytes, security_information) = - build_self_relative_security_descriptor(&to_write).map_err(acl_error)?; - let descriptor = PSECURITY_DESCRIPTOR(descriptor_bytes.as_mut_ptr().cast::()); + let mut prepared = prepare(path, requested)?; + set_prepared(path, &mut prepared) +} - let key = open(path, write_access(requested))?; +/// Reads, merges, normalizes, and builds the complete descriptor without changing the registry. +pub(crate) fn prepare( + path: &str, + requested: &RegistryAccessControlList, +) -> Result { + validate(requested)?; + ensure_privilege_for(requested)?; + let effective = effective(path, requested)?; + let (descriptor_bytes, security_information) = + build_self_relative_security_descriptor(&effective).map_err(acl_error)?; + Ok(PreparedAccessControlList { + effective, + descriptor_bytes, + security_information, + }) +} + +/// Writes a descriptor returned by [`prepare`] without rereading or rebuilding it. +pub(crate) fn set_prepared( + path: &str, + prepared: &mut PreparedAccessControlList, +) -> Result<(), RegistryError> { + let descriptor = + PSECURITY_DESCRIPTOR(prepared.descriptor_bytes.as_mut_ptr().cast::()); + let key = open(path, write_access(&prepared.effective))?; // SAFETY: descriptor_bytes remains alive for the duration of this call. - let status = unsafe { RegSetKeySecurity(key.0, security_information, descriptor) }; + let status = + unsafe { RegSetKeySecurity(key.0, prepared.security_information, descriptor) }; if status != ERROR_SUCCESS { return Err(win32_error("RegSetKeySecurity", status.0)); } diff --git a/lib/dsc-lib-registry/src/lib.rs b/lib/dsc-lib-registry/src/lib.rs index 37547a455..00442046c 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -319,6 +319,12 @@ impl RegistryHelper { }, Err(e) => return self.handle_error_or_what_if(e) }; + // Prepare the complete descriptor before writing a value. In merge mode this reads and + // parses the existing ACL, so unsupported existing ACEs fail before registry data changes. + let mut prepared_acl = match (&self.config.access_control_list, reg_key.is_some()) { + (Some(requested), true) => Some(acl::prepare(&self.config.key_path, requested)?), + _ => None, + }; if let Some(value_name) = &self.config.value_name { let value_data = match &self.config.value_data { @@ -369,7 +375,7 @@ impl RegistryHelper { if self.what_if { let mut what_if_acl = self.config.access_control_list.clone(); - if let Some(config_acl) = &self.config.access_control_list { + if self.config.access_control_list.is_some() { what_if_metadata.push( t!("registry_helper.whatIfSetAcl", key_path = &self.config.key_path).to_string(), ); @@ -380,8 +386,8 @@ impl RegistryHelper { // unresolved. A key that's only being simulated for creation has no current // state to merge with, so that simulation is kept separate and still returns // the raw (normalized-at-write-time) request. - if reg_key.is_some() { - what_if_acl = Some(acl::effective(&self.config.key_path, config_acl)?); + if let Some(effective_acl) = &prepared_acl { + what_if_acl = Some(effective_acl.effective().clone()); } } return Ok(Some(Registry { @@ -413,8 +419,10 @@ impl RegistryHelper { })); } - if let Some(acl) = &self.config.access_control_list { - acl::set(&self.config.key_path, acl)?; + if let Some(prepared) = &mut prepared_acl { + acl::set_prepared(&self.config.key_path, prepared)?; + } else if let Some(requested_acl) = &self.config.access_control_list { + acl::set(&self.config.key_path, requested_acl)?; } Ok(None) diff --git a/lib/lib-acl/src/win32/sid.rs b/lib/lib-acl/src/win32/sid.rs index bb6df91b2..b768a4e69 100644 --- a/lib/lib-acl/src/win32/sid.rs +++ b/lib/lib-acl/src/win32/sid.rs @@ -32,6 +32,12 @@ impl OwnedSid { /// Returns [`AclError::InvalidSid`] when `principal` looks like a SID string but is not valid, /// or [`AclError::PrincipalResolution`] when the account/group name cannot be resolved. pub fn resolve_principal(principal: &str) -> Result { + if principal.contains('\0') { + return Err(AclError::PrincipalResolution { + principal: principal.to_string(), + reason: "principal names must not contain NUL characters".to_string(), + }); + } if looks_like_sid(principal) { return parse_sid(principal); } @@ -234,4 +240,15 @@ mod tests { assert!(!looks_like_sid("DOMAIN\\User")); assert!(!looks_like_sid("Administrators")); } + + #[test] + fn rejects_principal_names_with_embedded_nul() { + let error = match resolve_principal("Everyone\0DSC-Invalid") { + Ok(_) => panic!("an embedded NUL must be rejected"), + Err(error) => error, + }; + + assert!(matches!(error, AclError::PrincipalResolution { .. })); + assert!(error.to_string().contains("must not contain NUL")); + } } diff --git a/resources/file_dsc/tests/filesystem_acl.tests.ps1 b/resources/file_dsc/tests/filesystem_acl.tests.ps1 index 61d654ed0..2661ca48b 100644 --- a/resources/file_dsc/tests/filesystem_acl.tests.ps1 +++ b/resources/file_dsc/tests/filesystem_acl.tests.ps1 @@ -422,6 +422,30 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { (Get-Content -Raw $TestDrive/error.log) | Should -Not -Match 'does not exist' } + It 'Rejects a principal containing an embedded NUL without changing the ACL' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'invalid-principal.txt') | + Select-Object -ExpandProperty FullName + $before = (Get-Acl -Path $path).Sddl + $json = @{ + path = $path + accessControlList = @{ + discretionary = @( + @{ + principal = "Everyone$([char]0)DSC-Invalid" + type = 'allow' + accessMask = @('read') + } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + + $null = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Not -Be 0 + (Get-Content -Raw $TestDrive/error.log) | Should -Match 'must not contain NUL' + (Get-Acl -Path $path).Sddl | Should -BeExactly $before + } + Context 'System access control list (SACL)' -Skip:(!$isElevated) { It 'Sets and gets Audit ACEs on a file' { $path = New-Item -ItemType File -Path (Join-Path $testRoot 'audit.txt') | diff --git a/resources/registry/tests/registry.config.set.tests.ps1 b/resources/registry/tests/registry.config.set.tests.ps1 index f6affc526..fdca121ff 100644 --- a/resources/registry/tests/registry.config.set.tests.ps1 +++ b/resources/registry/tests/registry.config.set.tests.ps1 @@ -555,7 +555,7 @@ Describe 'registry config set tests' { $keyPath = 'HKCU:\1\AclPreflight' $null = New-Item -Path $keyPath -Force Set-ItemProperty -Path $keyPath -Name 'Data' -Value 'original' - $missingAccount = "DSC-Missing-$([guid]::NewGuid())" + $missingAccount = "Everyone$([char]0)DSC-Invalid" $json = @{ keyPath = 'HKCU\1\AclPreflight' valueName = 'Data' @@ -572,4 +572,34 @@ Describe 'registry config set tests' { $LASTEXITCODE | Should -Not -Be 0 (Get-ItemPropertyValue -Path $keyPath -Name 'Data') | Should -BeExactly 'original' } + + It 'Prepares a merged ACL before changing a registry value' -Skip:(!$IsWindows) { + $keyPath = 'HKCU:\1\AclMergePreflight' + $null = New-Item -Path $keyPath -Force + Set-ItemProperty -Path $keyPath -Name 'Data' -Value 'original' + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $objectType = [guid]::NewGuid() + $security = [Security.AccessControl.RegistrySecurity]::new() + $security.SetSecurityDescriptorSddlForm( + "D:(A;;KA;;;$currentSid)(OA;;KR;$objectType;;WD)") + Set-Acl -Path $keyPath -AclObject $security + $json = @{ + keyPath = 'HKCU\1\AclMergePreflight' + valueName = 'Data' + valueData = @{ String = 'changed' } + accessControlList = @{ + discretionary = @( + @{ principal = $currentSid; type = 'allow'; accessMask = @('read') } + ) + _purge = $false + } + } | ConvertTo-Json -Depth 10 -Compress + + registry config set --input $json 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Not -Be 0 + (Get-Content -Path $TestDrive/error.log -Raw) | + Should -Match 'Access control entry type' + (Get-ItemPropertyValue -Path $keyPath -Name 'Data') | Should -BeExactly 'original' + } } From a540e669791507517af462b66ce3cb301f894b25 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 17 Oct 2026 17:07:43 -0700 Subject: [PATCH 7/9] Add ACL what-if result previews Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- lib/dsc-lib-registry/src/acl.rs | 16 +++ lib/dsc-lib-registry/src/lib.rs | 125 +++++++++++------- .../file_dsc/file_dsc.dsc.manifests.json | 6 +- resources/file_dsc/src/acl.rs | 10 +- resources/file_dsc/src/main.rs | 5 +- .../file_dsc/tests/filesystem_acl.tests.ps1 | 45 +++++++ .../tests/registry.config.whatif.tests.ps1 | 9 +- 7 files changed, 159 insertions(+), 57 deletions(-) diff --git a/lib/dsc-lib-registry/src/acl.rs b/lib/dsc-lib-registry/src/acl.rs index bb30d7bd0..547e417d3 100644 --- a/lib/dsc-lib-registry/src/acl.rs +++ b/lib/dsc-lib-registry/src/acl.rs @@ -248,6 +248,22 @@ pub fn effective( Ok(merge_access_control_list(¤t, &normalized)) } +/// Computes the effective ACL preview for a target that does not exist yet. There is no current +/// descriptor to merge, so merge mode starts from an empty managed ACL. This resolves principals +/// for every mode and applies the same replacement or merge semantics as a write, including +/// deduplication and canonical ordering in merge mode and effective protection defaults in +/// replacement mode. +pub fn effective_for_new( + requested: &RegistryAccessControlList, +) -> Result { + validate(requested)?; + let normalized = normalize(requested).map_err(acl_error)?; + Ok(merge_access_control_list( + &RegistryAccessControlList::default(), + &normalized, + )) +} + /// Returns whether `actual` (as returned by [`get`] for the section(s) `requested` manages) /// already satisfies `requested`, honoring its `_purge` setting exactly as [`effective`] does /// when computing what a [`set`] would write, but without performing a registry read: the merge diff --git a/lib/dsc-lib-registry/src/lib.rs b/lib/dsc-lib-registry/src/lib.rs index 00442046c..afb65f4b8 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -325,6 +325,17 @@ impl RegistryHelper { (Some(requested), true) => Some(acl::prepare(&self.config.key_path, requested)?), _ => None, }; + let what_if_acl = if let Some(prepared) = &prepared_acl { + Some(prepared.effective().clone()) + } else if self.what_if { + self.config + .access_control_list + .as_ref() + .map(acl::effective_for_new) + .transpose()? + } else { + None + }; if let Some(value_name) = &self.config.value_name { let value_data = match &self.config.value_data { @@ -374,21 +385,10 @@ impl RegistryHelper { }; if self.what_if { - let mut what_if_acl = self.config.access_control_list.clone(); if self.config.access_control_list.is_some() { what_if_metadata.push( t!("registry_helper.whatIfSetAcl", key_path = &self.config.key_path).to_string(), ); - // The key already exists (`reg_key` is `Some` whenever it was opened - // rather than merely simulated for creation above), so the what-if preview - // can -- and must -- compute the same effective, normalized/merged ACL that - // an actual `set` would write, instead of echoing the raw request back - // unresolved. A key that's only being simulated for creation has no current - // state to merge with, so that simulation is kept separate and still returns - // the raw (normalized-at-write-time) request. - if let Some(effective_acl) = &prepared_acl { - what_if_acl = Some(effective_acl.effective().clone()); - } } return Ok(Some(Registry { key_path: self.config.key_path.clone(), @@ -413,7 +413,7 @@ impl RegistryHelper { } return Ok(Some(Registry { key_path: self.config.key_path.clone(), - access_control_list: self.config.access_control_list.clone(), + access_control_list: what_if_acl, metadata: if what_if_metadata.is_empty() { None } else { Some(Metadata { what_if: Some(what_if_metadata) })}, ..Default::default() })); @@ -1192,7 +1192,7 @@ fn registry_access_right_generic_variants_have_expected_bits() { } #[test] -fn set_what_if_with_value_name_returns_effective_acl_for_existing_key() { +fn set_what_if_with_value_name_returns_effective_acl_for_all_purge_modes() { // Note the doubled backslash: `key_path` is embedded directly into JSON string literals // below, so it must already be JSON-escaped. let key_path = r"HKCU\\DSCTestNativeWhatIfExisting"; @@ -1206,27 +1206,34 @@ fn set_what_if_with_value_name_returns_effective_acl_for_existing_key() { ); RegistryHelper::new_from_json(&initial_json).unwrap().set().unwrap(); - // What-if a Set that both writes a value AND merges in a new ACE (`_purge: false`); since - // the key already exists, the preview must report the full effective (merged) ACL -- just - // like an actual Set would compute and apply -- not the raw, unmerged request. - let what_if_json = format!( - r#"{{"keyPath":"{key_path}","valueName":"Hello","valueData":{{"String":"World"}},"accessControlList":{{"discretionary":[{{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}}],"_purge":false}}}}"# - ); - let mut helper = RegistryHelper::new_from_json(&what_if_json).unwrap(); - helper.enable_what_if(); - - let result = helper.set().unwrap().expect("what-if returns a preview"); - - let principals: Vec = result - .access_control_list - .unwrap() - .discretionary - .unwrap() - .into_iter() - .map(|entry| entry.principal) - .collect(); - assert!(principals.contains(&"S-1-1-0".to_string()), "{principals:?}"); - assert!(principals.contains(&"S-1-5-19".to_string()), "{principals:?}"); + for (purge_property, should_merge) in [ + ("", false), + (r#","_purge":true"#, false), + (r#","_purge":false"#, true), + ] { + let what_if_json = format!( + r#"{{"keyPath":"{key_path}","valueName":"Hello","valueData":{{"String":"World"}},"accessControlList":{{"discretionary":[{{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}}]{purge_property}}}}}"# + ); + let mut helper = RegistryHelper::new_from_json(&what_if_json).unwrap(); + helper.enable_what_if(); + + let result = helper.set().unwrap().expect("what-if returns a preview"); + let principals: Vec = result + .access_control_list + .unwrap() + .discretionary + .unwrap() + .into_iter() + .map(|entry| entry.principal) + .collect(); + + assert_eq!( + principals.contains(&"S-1-1-0".to_string()), + should_merge, + "{principals:?}" + ); + assert!(principals.contains(&"S-1-5-19".to_string()), "{principals:?}"); + } // What-if must not have actually written the value. let actual = RegistryHelper::new_from_json(&format!( @@ -1244,22 +1251,44 @@ fn set_what_if_with_value_name_returns_effective_acl_for_existing_key() { } #[test] -fn set_what_if_with_value_name_returns_raw_acl_for_missing_key() { - let what_if_json = r#"{"keyPath":"HKCU\\DSCTestNativeWhatIfMissing","valueName":"Hello","valueData":{"String":"World"},"accessControlList":{"discretionary":[{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}]}}"#; - let mut helper = RegistryHelper::new_from_json(what_if_json).unwrap(); +fn set_what_if_for_missing_key_returns_effective_acl_for_all_purge_modes() { + for (purge_property, expected_count, expected_protection) in [ + ("", 3, Some(false)), + (r#","_purge":true"#, 3, Some(false)), + (r#","_purge":false"#, 2, None), + ] { + let what_if_json = format!( + r#"{{"keyPath":"HKCU\\DSCTestNativeWhatIfMissing","valueName":"Hello","valueData":{{"String":"World"}},"accessControlList":{{"discretionary":[{{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}},{{"principal":"S-1-5-7","type":"deny","accessMask":["read"]}},{{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}}]{purge_property}}}}}"# + ); + let mut helper = RegistryHelper::new_from_json(&what_if_json).unwrap(); + helper.enable_what_if(); + + let result = helper.set().unwrap().expect("what-if returns a preview"); + let acl = result.access_control_list.unwrap(); + let entries = acl.discretionary.unwrap(); + + assert_eq!(entries.len(), expected_count); + assert_eq!(acl.discretionary_protected, expected_protection); + if purge_property.contains("false") { + assert_eq!(entries[0].principal, "S-1-5-7"); + } + } +} + +#[test] +fn set_what_if_without_value_name_returns_effective_acl_for_missing_key() { + let mut helper = RegistryHelper::new_from_json( + r#"{"keyPath":"HKCU\\DSCTestNativeWhatIfMissingAclOnly","accessControlList":{"discretionary":[{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}]}}"#, + ) + .unwrap(); helper.enable_what_if(); let result = helper.set().unwrap().expect("what-if returns a preview"); + let acl = result.access_control_list.unwrap(); - // The key doesn't exist, so there is no current state to merge with; the preview reports - // the raw request rather than attempting (and failing) to read a nonexistent key. - let principals: Vec = result - .access_control_list - .unwrap() - .discretionary - .unwrap() - .into_iter() - .map(|entry| entry.principal) - .collect(); - assert_eq!(principals, vec!["S-1-5-19".to_string()]); + assert_eq!(acl.discretionary_protected, Some(false)); + assert_eq!( + acl.discretionary.unwrap()[0].principal, + "S-1-5-19".to_string() + ); } diff --git a/resources/file_dsc/file_dsc.dsc.manifests.json b/resources/file_dsc/file_dsc.dsc.manifests.json index 2a8fac5da..85415f805 100644 --- a/resources/file_dsc/file_dsc.dsc.manifests.json +++ b/resources/file_dsc/file_dsc.dsc.manifests.json @@ -128,10 +128,14 @@ { "jsonInputArg": "--input", "mandatory": true + }, + { + "whatIfArg": "--what-if" } ], "implementsPretest": false, - "return": "state" + "return": "state", + "whatIfReturns": "state" }, "test": { "executable": "file_dsc", diff --git a/resources/file_dsc/src/acl.rs b/resources/file_dsc/src/acl.rs index aded278e0..520dcc906 100644 --- a/resources/file_dsc/src/acl.rs +++ b/resources/file_dsc/src/acl.rs @@ -25,7 +25,7 @@ pub fn get(input: &FileAccessControlList) -> Result Result { +pub fn set(input: &FileAccessControlList, what_if: bool) -> Result { let requested = validate_input(input)?; let normalized = normalize(requested).map_err(acl_error)?; if normalized.system.is_some() { @@ -44,6 +44,14 @@ pub fn set(input: &FileAccessControlList) -> Result ExitCode { Ok(input) => input, Err(code) => return code, }; + let what_if = args.iter().any(|arg| arg == "--what-if" || arg == "-w"); #[cfg(windows)] { match operation.as_str() { "get" => handle_result(acl::get(&input)), - "set" => handle_result(acl::set(&input)), + "set" => handle_result(acl::set(&input, what_if)), "test" => handle_result(acl::test(&input)), _ => unreachable!(), } @@ -86,7 +87,7 @@ fn handle_acl(args: &[String]) -> ExitCode { #[cfg(not(windows))] { - let _ = (operation, input); + let _ = (operation, input, what_if); fail(EXIT_RESOURCE_ERROR, &t!("acl.windowsOnly")) } } diff --git a/resources/file_dsc/tests/filesystem_acl.tests.ps1 b/resources/file_dsc/tests/filesystem_acl.tests.ps1 index 2661ca48b..40a7fdcd5 100644 --- a/resources/file_dsc/tests/filesystem_acl.tests.ps1 +++ b/resources/file_dsc/tests/filesystem_acl.tests.ps1 @@ -126,6 +126,51 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { } Context '_purge behavior' { + It 'What-if returns the resulting ACL without changing the file when _purge is ' -ForEach @( + @{ purgeMode = 'omitted'; purge = $null; expectedCount = 1; expectExisting = $false } + @{ purgeMode = 'true'; purge = $true; expectedCount = 1; expectExisting = $false } + @{ purgeMode = 'false'; purge = $false; expectedCount = 2; expectExisting = $true } + ) { + $path = New-Item -ItemType File -Path (Join-Path $testRoot "whatif-$purgeMode.txt") | + Select-Object -ExpandProperty FullName + $initialAcl = @{ + discretionary = @( + @{ principal = $currentSid; type = 'allow'; accessMask = @('fullControl') } + ) + } + $initialJson = @{ path = $path; accessControlList = $initialAcl } | + ConvertTo-Json -Depth 10 -Compress + $null = $initialJson | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $requestedAcl = @{ + discretionary = @( + @{ principal = 'ANONYMOUS LOGON'; type = 'deny'; accessMask = @('read') } + ) + } + if ($null -ne $purge) { + $requestedAcl._purge = $purge + } + $whatIfJson = @{ path = $path; accessControlList = $requestedAcl } | + ConvertTo-Json -Depth 10 -Compress + + $whatIfOutput = $whatIfJson | + dsc resource set -r $resourceType -f - -w 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $afterState = ($whatIfOutput | ConvertFrom-Json).afterState + + @($afterState.accessControlList.discretionary).Count | Should -Be $expectedCount + $afterState.accessControlList.discretionary[0].principal | Should -BeExactly $anonymousSid + ($afterState.accessControlList.discretionary.principal -contains $currentSid) | + Should -Be $expectExisting + + $actualState = ($initialJson | + dsc resource get -r $resourceType -f - 2>$TestDrive/error.log | + ConvertFrom-Json).actualState + @($actualState.accessControlList.discretionary).Count | Should -Be 1 + $actualState.accessControlList.discretionary[0].principal | Should -BeExactly $currentSid + } + It 'Replaces the discretionary section outright when _purge is omitted (default true)' { $path = New-Item -ItemType File -Path (Join-Path $testRoot 'replace.txt') | Select-Object -ExpandProperty FullName diff --git a/resources/registry/tests/registry.config.whatif.tests.ps1 b/resources/registry/tests/registry.config.whatif.tests.ps1 index e0de4208a..6d10ebb41 100644 --- a/resources/registry/tests/registry.config.whatif.tests.ps1 +++ b/resources/registry/tests/registry.config.whatif.tests.ps1 @@ -153,24 +153,23 @@ Describe 'registry config whatif tests' { $afterGet.accessControlList.discretionary.Count | Should -Be 1 } - It 'Can whatif setting a value and ACL on a missing key, returning the raw (unmerged) request' -Skip:(!$IsWindows) { + It 'Can whatif setting a value and ACL on a missing key, returning the resulting ACL' -Skip:(!$IsWindows) { $whatIfJson = @{ keyPath = 'HKCU\1\AclWhatIfMissingKey' valueName = 'Hello' valueData = @{ String = 'World' } accessControlList = @{ discretionary = @( - @{ principal = 'S-1-5-7'; type = 'deny'; accessMask = @('read') } + @{ principal = $env:USERNAME; type = 'deny'; accessMask = @('read') } ) } } | ConvertTo-Json -Depth 10 -Compress $result = registry config set -w --input $whatIfJson 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) - # The key doesn't exist, so the preview falls back to reporting the raw request rather - # than attempting (and failing) to read a nonexistent key's current ACL. $result.accessControlList.discretionary.Count | Should -Be 1 - $result.accessControlList.discretionary[0].principal | Should -BeExactly 'S-1-5-7' + $result.accessControlList.discretionary[0].principal | Should -Match '^S-\d-' + $result.accessControlList.discretionaryProtected | Should -BeFalse } It 'Can whatif an existing deeply nested key and value' -Skip:(!$IsWindows) { From 983e2f7e3c7432cff207104281b4262f738ca711 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 17 Oct 2026 18:06:18 -0700 Subject: [PATCH 8/9] Fix inherited ACL previews and localization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 +- lib/dsc-lib-registry/src/acl.rs | 214 ++++++++++++++++-- lib/dsc-lib-registry/src/lib.rs | 72 +++++- lib/lib-acl/Cargo.toml | 7 +- lib/lib-acl/locales/en-us.toml | 26 +++ lib/lib-acl/src/error.rs | 63 ++++-- lib/lib-acl/src/lib.rs | 2 + lib/lib-acl/src/model.rs | 13 +- lib/lib-acl/src/win32/sid.rs | 5 +- .../tests/registry.config.set.tests.ps1 | 7 +- 10 files changed, 355 insertions(+), 56 deletions(-) create mode 100644 lib/lib-acl/locales/en-us.toml diff --git a/Cargo.lock b/Cargo.lock index 18907e07f..4f246178b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1923,9 +1923,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" name = "lib-acl" version = "0.1.0" dependencies = [ + "rust-i18n", "schemars", "serde", - "thiserror 2.0.21", "windows 0.62.2", ] diff --git a/lib/dsc-lib-registry/src/acl.rs b/lib/dsc-lib-registry/src/acl.rs index 547e417d3..6e5ed7247 100644 --- a/lib/dsc-lib-registry/src/acl.rs +++ b/lib/dsc-lib-registry/src/acl.rs @@ -3,20 +3,26 @@ use core::ffi::c_void; -use lib_acl::AccessControlList; +use lib_acl::{AccessControlList, AccessMask, AceFlag}; use lib_acl::win32::{ build_self_relative_security_descriptor, enable_security_privilege, normalize, read_access_control_list, read_security_information, }; use lib_acl::merge_access_control_list; use windows::Win32::Foundation::{ - ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, ERROR_PATH_NOT_FOUND, ERROR_SUCCESS, + CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, ERROR_PATH_NOT_FOUND, + ERROR_SUCCESS, HANDLE, +}; +use windows::Win32::Security::{ + CreatePrivateObjectSecurityEx, DestroyPrivateObjectSecurity, GENERIC_MAPPING, + GetSecurityDescriptorLength, OBJECT_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + SECURITY_AUTO_INHERIT_FLAGS, SEF_DACL_AUTO_INHERIT, SEF_SACL_AUTO_INHERIT, TOKEN_QUERY, }; -use windows::Win32::Security::{OBJECT_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR}; use windows::Win32::System::Registry::{ HKEY, HKEY_CLASSES_ROOT, HKEY_CURRENT_CONFIG, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, HKEY_USERS, REG_SAM_FLAGS, RegCloseKey, RegGetKeySecurity, RegOpenKeyExW, RegSetKeySecurity, }; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; use windows::core::{HSTRING, PCWSTR}; use crate::config::RegistryAccessRight; @@ -57,6 +63,98 @@ impl Drop for RegistryKey { } } +struct ProcessToken(HANDLE); + +impl ProcessToken { + fn open() -> Result { + let mut token = HANDLE::default(); + // SAFETY: GetCurrentProcess returns a valid pseudo-handle, and token is writable. + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } + .map_err(|error| win32_error("OpenProcessToken", error.code().0 as u32))?; + Ok(Self(token)) + } +} + +impl Drop for ProcessToken { + fn drop(&mut self) { + // SAFETY: This handle was returned by OpenProcessToken and is owned by this wrapper. + unsafe { + let _ = CloseHandle(self.0); + } + } +} + +struct PrivateSecurityDescriptor(PSECURITY_DESCRIPTOR); + +impl PrivateSecurityDescriptor { + fn inherit_from( + parent: PSECURITY_DESCRIPTOR, + requested: &RegistryAccessControlList, + token: HANDLE, + ) -> Result { + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + let mut flags = SECURITY_AUTO_INHERIT_FLAGS(0); + if requested.discretionary.is_some() { + flags |= SEF_DACL_AUTO_INHERIT; + } + if requested.system.is_some() { + flags |= SEF_SACL_AUTO_INHERIT; + } + let mapping = GENERIC_MAPPING { + GenericRead: RegistryAccessRight::Read.bits(), + GenericWrite: RegistryAccessRight::Write.bits(), + GenericExecute: RegistryAccessRight::Execute.bits(), + GenericAll: RegistryAccessRight::FullControl.bits(), + }; + + // SAFETY: parent points to a valid self-relative descriptor, descriptor is writable, + // token is a valid queryable process token, and mapping lives for the duration of the call. + unsafe { + CreatePrivateObjectSecurityEx( + Some(parent), + None, + &mut descriptor, + None, + true, + flags, + Some(token), + &raw const mapping, + ) + } + .map_err(|error| { + win32_error( + "CreatePrivateObjectSecurityEx", + error.code().0 as u32, + ) + })?; + Ok(Self(descriptor)) + } + + fn as_ptr(&self) -> PSECURITY_DESCRIPTOR { + self.0 + } + + fn to_bytes(&self) -> Result, RegistryError> { + // SAFETY: self owns a valid descriptor returned by CreatePrivateObjectSecurityEx. + let length = unsafe { GetSecurityDescriptorLength(self.0) } as usize; + if length == 0 { + return Err(win32_error("GetSecurityDescriptorLength", 0)); + } + // SAFETY: GetSecurityDescriptorLength returned the readable size of this descriptor. + Ok(unsafe { core::slice::from_raw_parts(self.0.0.cast::(), length) }.to_vec()) + } +} + +impl Drop for PrivateSecurityDescriptor { + fn drop(&mut self) { + // SAFETY: This descriptor was allocated by CreatePrivateObjectSecurityEx and is owned by + // this wrapper. DestroyPrivateObjectSecurity releases it and clears the pointer. + unsafe { + let _ = DestroyPrivateObjectSecurity(&raw const self.0); + } + } +} + /// Validates that `requested` is well-formed: that it specifies at least one of `discretionary` /// or `system` (an `accessControlList` with neither is never meaningful, since there would be /// nothing to read, write, or compare), and that every entry is well-formed for the section it @@ -120,9 +218,21 @@ pub fn get( let want_discretionary = requested.discretionary.is_some(); let want_system = requested.system.is_some(); - ensure_privilege_for(requested)?; + let mut descriptor_bytes = read_descriptor(path, requested)?; + // SAFETY: descriptor_bytes was populated by RegGetKeySecurity and is a complete, well-formed + // self-relative security descriptor. + unsafe { read_access_control_list(&mut descriptor_bytes, want_discretionary, want_system) } + .map_err(acl_error) +} +fn read_descriptor( + path: &str, + requested: &RegistryAccessControlList, +) -> Result, RegistryError> { + ensure_privilege_for(requested)?; let key = open(path, read_access(requested))?; + let want_discretionary = requested.discretionary.is_some(); + let want_system = requested.system.is_some(); let security_information = read_security_information(want_discretionary, want_system); let mut size = 0; @@ -141,11 +251,7 @@ pub fn get( return Err(win32_error("RegGetKeySecurity", status.0)); } - // SAFETY: descriptor_bytes was just populated in-place by a successful RegGetKeySecurity - // call above, sized exactly to the `size` that same API reported; it is therefore a - // complete, well-formed self-relative security descriptor. - unsafe { read_access_control_list(&mut descriptor_bytes, want_discretionary, want_system) } - .map_err(acl_error) + Ok(descriptor_bytes) } /// Returns whether `path` currently exists and could accept `requested` being applied via @@ -248,20 +354,92 @@ pub fn effective( Ok(merge_access_control_list(¤t, &normalized)) } -/// Computes the effective ACL preview for a target that does not exist yet. There is no current -/// descriptor to merge, so merge mode starts from an empty managed ACL. This resolves principals -/// for every mode and applies the same replacement or merge semantics as a write, including -/// deduplication and canonical ordering in merge mode and effective protection defaults in -/// replacement mode. +/// Computes the effective ACL preview for a target that does not exist yet. Replacement mode does +/// not depend on existing state. Merge mode finds the nearest existing parent and uses Windows' +/// native inheritance algorithm for every missing path component before merging the requested +/// entries, so the preview includes the entries that registry key creation will propagate. pub fn effective_for_new( + path: &str, requested: &RegistryAccessControlList, ) -> Result { validate(requested)?; let normalized = normalize(requested).map_err(acl_error)?; - Ok(merge_access_control_list( - &RegistryAccessControlList::default(), - &normalized, - )) + if normalized.purge() { + return Ok(merge_access_control_list( + &RegistryAccessControlList::default(), + &normalized, + )); + } + + let (parent_path, missing_levels) = nearest_existing_parent(path, read_access(&normalized))?; + let mut parent_bytes = read_descriptor(&parent_path, &normalized)?; + let token = ProcessToken::open()?; + let mut inherited: Option = None; + for _ in 0..missing_levels { + let parent = inherited.as_ref().map_or_else( + || PSECURITY_DESCRIPTOR(parent_bytes.as_mut_ptr().cast::()), + PrivateSecurityDescriptor::as_ptr, + ); + inherited = Some(PrivateSecurityDescriptor::inherit_from( + parent, &normalized, token.0, + )?); + } + let Some(inherited) = inherited else { + return effective(path, &normalized); + }; + let mut inherited_bytes = inherited.to_bytes()?; + // SAFETY: inherited_bytes is a copy of the complete descriptor created by + // CreatePrivateObjectSecurityEx and remains alive for this call. + let mut current = unsafe { + read_access_control_list( + &mut inherited_bytes, + normalized.discretionary.is_some(), + normalized.system.is_some(), + ) + } + .map_err(acl_error)?; + // Registry key creation copies inherited ACEs onto the new key without retaining the + // INHERITED_ACE marker. CreatePrivateObjectSecurityEx performs the same propagation rules but + // sets that marker, so remove it to match the descriptor RegCreateKeyExW actually produces. + for entries in [&mut current.discretionary, &mut current.system] + .into_iter() + .flatten() + { + for entry in entries { + entry.flags.retain(|flag| *flag != AceFlag::Inherited); + } + } + + Ok(merge_access_control_list(¤t, &normalized)) +} + +fn nearest_existing_parent( + path: &str, + access: REG_SAM_FLAGS, +) -> Result<(String, usize), RegistryError> { + let (hive, subkey) = path + .split_once('\\') + .map_or((path, ""), |(hive, subkey)| (hive, subkey)); + split_path(path)?; + let parts: Vec<&str> = subkey + .split('\\') + .filter(|part| !part.is_empty()) + .collect(); + + for existing_count in (0..parts.len()).rev() { + let candidate = if existing_count == 0 { + hive.to_string() + } else { + format!("{hive}\\{}", parts[..existing_count].join("\\")) + }; + match open(&candidate, access) { + Ok(_) => return Ok((candidate, parts.len() - existing_count)), + Err(RegistryError::RegistryKeyNotFound(_)) => {} + Err(error) => return Err(error), + } + } + + Err(RegistryError::RegistryKeyNotFound(path.to_string())) } /// Returns whether `actual` (as returned by [`get`] for the section(s) `requested` manages) diff --git a/lib/dsc-lib-registry/src/lib.rs b/lib/dsc-lib-registry/src/lib.rs index afb65f4b8..64d18b5df 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -331,7 +331,7 @@ impl RegistryHelper { self.config .access_control_list .as_ref() - .map(acl::effective_for_new) + .map(|requested| acl::effective_for_new(&self.config.key_path, requested)) .transpose()? } else { None @@ -1253,9 +1253,9 @@ fn set_what_if_with_value_name_returns_effective_acl_for_all_purge_modes() { #[test] fn set_what_if_for_missing_key_returns_effective_acl_for_all_purge_modes() { for (purge_property, expected_count, expected_protection) in [ - ("", 3, Some(false)), - (r#","_purge":true"#, 3, Some(false)), - (r#","_purge":false"#, 2, None), + ("", Some(3), Some(false)), + (r#","_purge":true"#, Some(3), Some(false)), + (r#","_purge":false"#, None, Some(false)), ] { let what_if_json = format!( r#"{{"keyPath":"HKCU\\DSCTestNativeWhatIfMissing","valueName":"Hello","valueData":{{"String":"World"}},"accessControlList":{{"discretionary":[{{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}},{{"principal":"S-1-5-7","type":"deny","accessMask":["read"]}},{{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}}]{purge_property}}}}}"# @@ -1267,10 +1267,19 @@ fn set_what_if_for_missing_key_returns_effective_acl_for_all_purge_modes() { let acl = result.access_control_list.unwrap(); let entries = acl.discretionary.unwrap(); - assert_eq!(entries.len(), expected_count); + if let Some(expected_count) = expected_count { + assert_eq!(entries.len(), expected_count); + } assert_eq!(acl.discretionary_protected, expected_protection); if purge_property.contains("false") { assert_eq!(entries[0].principal, "S-1-5-7"); + assert_eq!( + entries + .iter() + .filter(|entry| entry.principal == "S-1-5-19") + .count(), + 1 + ); } } } @@ -1292,3 +1301,56 @@ fn set_what_if_without_value_name_returns_effective_acl_for_missing_key() { "S-1-5-19".to_string() ); } + +#[test] +fn set_what_if_for_missing_key_includes_the_acl_set_will_inherit() { + let parent_path = r"HKCU\\DSCTestNativeWhatIfInheritance"; + let target_path = format!(r"{parent_path}\\Child\\Grandchild"); + let _ = RegistryHelper::new_from_json(&format!(r#"{{"keyPath":"{parent_path}"}}"#)) + .unwrap() + .remove(); + + let parent_json = format!( + r#"{{"keyPath":"{parent_path}","accessControlList":{{"discretionary":[{{"principal":"S-1-1-0","type":"allow","accessMask":["fullControl"],"flags":["containerInherit"]}}]}}}}"# + ); + RegistryHelper::new_from_json(&parent_json) + .unwrap() + .set() + .unwrap(); + + let request_json = format!( + r#"{{"keyPath":"{target_path}","valueName":"Value","valueData":{{"String":"Data"}},"accessControlList":{{"discretionary":[{{"principal":"S-1-5-19","type":"allow","accessMask":["read"]}}],"_purge":false}}}}"# + ); + let mut what_if_helper = RegistryHelper::new_from_json(&request_json).unwrap(); + what_if_helper.enable_what_if(); + let preview = what_if_helper + .set() + .unwrap() + .expect("what-if returns a preview") + .access_control_list + .unwrap(); + + RegistryHelper::new_from_json(&request_json) + .unwrap() + .set() + .unwrap(); + let actual = RegistryHelper::new_from_json(&format!( + r#"{{"keyPath":"{target_path}","accessControlList":{{"discretionary":[]}}}}"# + )) + .unwrap() + .get() + .unwrap() + .access_control_list + .unwrap(); + + RegistryHelper::new_from_json(&format!(r#"{{"keyPath":"{parent_path}"}}"#)) + .unwrap() + .remove() + .unwrap(); + + assert_eq!(preview.discretionary, actual.discretionary); + assert_eq!( + preview.discretionary_protected, + actual.discretionary_protected + ); +} diff --git a/lib/lib-acl/Cargo.toml b/lib/lib-acl/Cargo.toml index 8b3e1808f..4e841003a 100644 --- a/lib/lib-acl/Cargo.toml +++ b/lib/lib-acl/Cargo.toml @@ -6,13 +6,18 @@ name = "lib-acl" version = "0.1.0" edition = "2024" +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + [lib] doctest = false [dependencies] +rust-i18n = { workspace = true } schemars = { workspace = true } serde = { workspace = true } -thiserror = { workspace = true } [target.'cfg(windows)'.dependencies] windows = { workspace = true } diff --git a/lib/lib-acl/locales/en-us.toml b/lib/lib-acl/locales/en-us.toml new file mode 100644 index 000000000..c89b22cf4 --- /dev/null +++ b/lib/lib-acl/locales/en-us.toml @@ -0,0 +1,26 @@ +_version = 1 + +[error] +invalidEntry = "Invalid access control entry: %{reason}" +principalResolution = "Failed to resolve principal '%{principal}' to a security identifier: %{reason}" +invalidSid = "'%{value}' is not a valid security identifier (SID) string" +win32 = "%{operation} failed with error code %{code}" +privilegeNotHeld = "The '%{privilege}' privilege is required to read or write the system access control list (SACL) but is not held by the current process. Run with elevated (Administrator) rights and ensure the account has the 'Manage auditing and security log' user right assigned." +unsupportedAceType = "Access control entry type %{ace_type} is not supported" +unrepresentableAccessMask = "Access mask %{mask} includes one or more bits that are not recognized by this resource's permission set and would be silently discarded" +nullAcl = "The %{section} access control list is NULL (absent), which Windows treats as unrestricted; this is not supported as equivalent to an empty access control list" + +[section] +discretionary = "discretionary" +system = "system" + +[validation] +discretionaryType = "discretionary access control entries must have type 'allow' or 'deny'" +discretionaryAuditFlags = "discretionary access control entries must not set auditFlags" +accessMaskRequired = "an access control entry must specify at least one accessMask value" +systemType = "system access control entries must have type 'audit'" +systemAuditFlags = "system access control entries must set at least one auditFlags value" + +[principal] +nulCharacter = "principal names must not contain NUL characters" +notFound = "the account or group name could not be found" diff --git a/lib/lib-acl/src/error.rs b/lib/lib-acl/src/error.rs index 2610b9acc..1d0bb4c49 100644 --- a/lib/lib-acl/src/error.rs +++ b/lib/lib-acl/src/error.rs @@ -1,42 +1,33 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use thiserror::Error; +use rust_i18n::t; +use std::fmt::{Display, Formatter}; /// Errors that can occur while modeling or applying an access control list. -#[derive(Debug, Error)] +#[derive(Debug)] #[allow(clippy::module_name_repetitions)] pub enum AclError { /// An access control entry's shape is invalid for the section it belongs to, for example an /// `audit` entry in a discretionary list, or an `allow`/`deny` entry in a system list. - #[error("Invalid access control entry: {0}")] InvalidEntry(String), /// The principal (SID string or account name) could not be resolved to a security /// identifier. - #[error("Failed to resolve principal '{principal}' to a security identifier: {reason}")] PrincipalResolution { principal: String, reason: String }, /// A value that was expected to be a SID string was not formatted correctly. - #[error("'{0}' is not a valid security identifier (SID) string")] InvalidSid(String), /// A Win32 API call failed. - #[error("{operation} failed with error code {code}")] Win32 { operation: &'static str, code: u32 }, /// The current process does not hold a privilege required to manage the system access /// control list (SACL). - #[error( - "The '{0}' privilege is required to read or write the system access control list (SACL) \ - but is not held by the current process. Run with elevated (Administrator) rights and \ - ensure the account has the 'Manage auditing and security log' user right assigned." - )] PrivilegeNotHeld(&'static str), /// An access control entry of an unsupported type (for example a conditional or /// object-specific ACE) was encountered while reading an existing ACL. - #[error("Access control entry type {0} is not supported")] UnsupportedAceType(u8), /// An access mask read from an existing access control entry contains one or more bits that @@ -44,17 +35,49 @@ pub enum AclError { /// Returned instead of silently discarding the unrecognized bits, which would otherwise let /// a subsequent merge-and-rewrite of the access control list narrow its effective access /// mask without any indication that it had done so. - #[error( - "Access mask {0:#010x} includes one or more bits that are not recognized by this \ - resource's permission set and would be silently discarded" - )] UnrepresentableAccessMask(u32), /// The discretionary access control list is NULL (absent), which grants unrestricted access. /// This is distinct from a present-but-empty DACL, which denies all access by default. - #[error( - "The {0} access control list is NULL (absent), which Windows treats as unrestricted; \ - this is not supported as equivalent to an empty access control list" - )] NullAcl(&'static str), } + +impl Display for AclError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::InvalidEntry(reason) => { + t!("error.invalidEntry", reason = reason).to_string() + } + Self::PrincipalResolution { principal, reason } => t!( + "error.principalResolution", + principal = principal, + reason = reason + ) + .to_string(), + Self::InvalidSid(value) => t!("error.invalidSid", value = value).to_string(), + Self::Win32 { operation, code } => { + t!("error.win32", operation = operation, code = code).to_string() + } + Self::PrivilegeNotHeld(privilege) => { + t!("error.privilegeNotHeld", privilege = privilege).to_string() + } + Self::UnsupportedAceType(ace_type) => { + t!("error.unsupportedAceType", ace_type = ace_type).to_string() + } + Self::UnrepresentableAccessMask(mask) => { + t!("error.unrepresentableAccessMask", mask = format!("{mask:#010x}")).to_string() + } + Self::NullAcl(section) => { + let section = match *section { + "discretionary" => t!("section.discretionary"), + "system" => t!("section.system"), + _ => (*section).into(), + }; + t!("error.nullAcl", section = section).to_string() + } + }; + formatter.write_str(&message) + } +} + +impl std::error::Error for AclError {} diff --git a/lib/lib-acl/src/lib.rs b/lib/lib-acl/src/lib.rs index 22cb0dae3..04f9059b9 100644 --- a/lib/lib-acl/src/lib.rs +++ b/lib/lib-acl/src/lib.rs @@ -8,6 +8,8 @@ //! platform so that resources can share the JSON shape and schema regardless of the host OS. //! The [`win32`] module, which actually talks to Win32 APIs, is only compiled on Windows. +rust_i18n::i18n!("locales", fallback = "en-us"); + pub mod error; pub mod model; diff --git a/lib/lib-acl/src/model.rs b/lib/lib-acl/src/model.rs index 25d03e704..95d448501 100644 --- a/lib/lib-acl/src/model.rs +++ b/lib/lib-acl/src/model.rs @@ -8,6 +8,7 @@ //! on Windows. use crate::error::AclError; +use rust_i18n::t; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -158,17 +159,17 @@ impl AccessControlEntry

{ pub fn validate_discretionary(&self) -> Result<(), AclError> { if !matches!(self.ace_type, AceType::Allow | AceType::Deny) { return Err(AclError::InvalidEntry( - "discretionary access control entries must have type 'allow' or 'deny'".to_string(), + t!("validation.discretionaryType").to_string(), )); } if !self.audit_flags.is_empty() { return Err(AclError::InvalidEntry( - "discretionary access control entries must not set auditFlags".to_string(), + t!("validation.discretionaryAuditFlags").to_string(), )); } if self.access_mask.is_empty() { return Err(AclError::InvalidEntry( - "an access control entry must specify at least one accessMask value".to_string(), + t!("validation.accessMaskRequired").to_string(), )); } Ok(()) @@ -183,17 +184,17 @@ impl AccessControlEntry

{ pub fn validate_system(&self) -> Result<(), AclError> { if self.ace_type != AceType::Audit { return Err(AclError::InvalidEntry( - "system access control entries must have type 'audit'".to_string(), + t!("validation.systemType").to_string(), )); } if self.audit_flags.is_empty() { return Err(AclError::InvalidEntry( - "system access control entries must set at least one auditFlags value".to_string(), + t!("validation.systemAuditFlags").to_string(), )); } if self.access_mask.is_empty() { return Err(AclError::InvalidEntry( - "an access control entry must specify at least one accessMask value".to_string(), + t!("validation.accessMaskRequired").to_string(), )); } Ok(()) diff --git a/lib/lib-acl/src/win32/sid.rs b/lib/lib-acl/src/win32/sid.rs index b768a4e69..cfe7f2b7f 100644 --- a/lib/lib-acl/src/win32/sid.rs +++ b/lib/lib-acl/src/win32/sid.rs @@ -6,6 +6,7 @@ use crate::error::AclError; use core::ffi::c_void; +use rust_i18n::t; use windows::Win32::Foundation::{HLOCAL, LocalFree}; use windows::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSidToSidW}; use windows::Win32::Security::{GetLengthSid, LookupAccountNameW, PSID, SID_NAME_USE}; @@ -35,7 +36,7 @@ pub fn resolve_principal(principal: &str) -> Result { if principal.contains('\0') { return Err(AclError::PrincipalResolution { principal: principal.to_string(), - reason: "principal names must not contain NUL characters".to_string(), + reason: t!("principal.nulCharacter").to_string(), }); } if looks_like_sid(principal) { @@ -168,7 +169,7 @@ fn lookup_account_name(name: &str) -> Result { if sid_size == 0 { return Err(AclError::PrincipalResolution { principal: name.to_string(), - reason: "the account or group name could not be found".to_string(), + reason: t!("principal.notFound").to_string(), }); } diff --git a/resources/registry/tests/registry.config.set.tests.ps1 b/resources/registry/tests/registry.config.set.tests.ps1 index fdca121ff..6cc3c586b 100644 --- a/resources/registry/tests/registry.config.set.tests.ps1 +++ b/resources/registry/tests/registry.config.set.tests.ps1 @@ -579,10 +579,11 @@ Describe 'registry config set tests' { Set-ItemProperty -Path $keyPath -Name 'Data' -Value 'original' $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value $objectType = [guid]::NewGuid() - $security = [Security.AccessControl.RegistrySecurity]::new() + $security = Get-Acl -Path $keyPath $security.SetSecurityDescriptorSddlForm( - "D:(A;;KA;;;$currentSid)(OA;;KR;$objectType;;WD)") - Set-Acl -Path $keyPath -AclObject $security + "D:(A;;KA;;;$currentSid)(OA;;KR;$objectType;;WD)", + [Security.AccessControl.AccessControlSections]::Access) + Set-Acl -Path $keyPath -AclObject $security -ErrorAction Stop $json = @{ keyPath = 'HKCU\1\AclMergePreflight' valueName = 'Data' From cb9b7bb99729e6b845d49da3b93657594a4ea6a1 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 17 Oct 2026 20:25:20 -0700 Subject: [PATCH 9/9] Refine ACL purge section semantics Treat omitted _purge as replacement only for supplied sections, while explicit true manages and clears both omitted ACL sections. Keep merge behavior for false and update previews, schemas, docs, diagnostics, and regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Microsoft/Windows/Registry/index.md | 60 +++--- lib/dsc-lib-registry/locales/en-us.toml | 2 +- lib/dsc-lib-registry/src/acl.rs | 80 ++++--- lib/dsc-lib-registry/src/lib.rs | 20 +- lib/lib-acl/src/model.rs | 195 +++++++++++------- lib/lib-acl/src/win32/security_descriptor.rs | 8 +- .../file_dsc/file_dsc.dsc.manifests.json | 15 +- resources/file_dsc/locales/en-us.toml | 2 +- resources/file_dsc/src/acl.rs | 18 +- resources/file_dsc/src/types.rs | 4 +- .../file_dsc/tests/filesystem_acl.tests.ps1 | 28 ++- .../tests/registry.config.set.tests.ps1 | 6 +- .../tests/registry.config.test.tests.ps1 | 2 +- .../tests/registry.config.whatif.tests.ps1 | 25 +++ 14 files changed, 287 insertions(+), 178 deletions(-) diff --git a/docs/reference/resources/Microsoft/Windows/Registry/index.md b/docs/reference/resources/Microsoft/Windows/Registry/index.md index 7dcbb5daf..43b3c4be3 100644 --- a/docs/reference/resources/Microsoft/Windows/Registry/index.md +++ b/docs/reference/resources/Microsoft/Windows/Registry/index.md @@ -215,8 +215,8 @@ registry hive. The resource doesn't manage the key's owner or primary group. - [system](#system-accesscontrollist) - The system access control list (SACL) entries. - [systemProtected](#systemprotected-accesscontrollist) - Whether the SACL is protected from inheriting entries from its parent. -- [_purge](#_purge-accesscontrollist) - Whether to replace or merge the supplied section(s). - Defaults to `true`. +- [_purge](#_purge-accesscontrollist) - Whether to replace supplied sections, purge both sections, + or merge supplied sections. #### discretionary accessControlList @@ -232,9 +232,10 @@ ItemsMinimumCount : 0

Defines the discretionary access control list (DACL) entries for the key. Omit this property to -leave the DACL unmanaged. Specify an empty array to request the current entries without changing -them, or during a **Set** operation, to clear every explicit entry from the DACL. Each item is an -[access control entry object](#access-control-entry-object). +leave the DACL unmanaged unless `_purge` is explicitly `true`, in which case omission clears the +DACL. Specify an empty array to request the current entries without changing them, or during a +**Set** operation, to clear every explicit entry from the DACL. Each item is an [access control +entry object](#access-control-entry-object). #### discretionaryProtected accessControlList @@ -246,12 +247,9 @@ Type : boolean -Defines whether the DACL is protected from inheriting entries from its parent key. Only meaningful -when `discretionary` is also specified. Omitting this property behaves according to `_purge`: when -purging (the default), the DACL is written as unprotected (inheriting), since a purge write always -stamps an explicit protection state; when merging (`_purge: false`), the current protection state -is preserved unchanged. Set this explicitly to request a particular protection state regardless of -`_purge`. +Defines whether the DACL is protected from inheriting entries from its parent key. This is +meaningful when `discretionary` is specified or `_purge` is explicitly `true`. For a managed DACL, +omission uses `false` for replacement and preserves the current value for merge. #### system accessControlList @@ -267,9 +265,10 @@ ItemsMinimumCount : 0 Defines the system access control list (SACL) entries for the key. Omit this property to leave the -SACL unmanaged. Specify an empty array to request the current entries without changing them, or -during a **Set** operation, to clear every explicit entry from the SACL. Every entry must have a -`type` of `audit`. Each item is an [access control entry object](#access-control-entry-object). +SACL unmanaged unless `_purge` is explicitly `true`, in which case omission clears the SACL. +Specify an empty array to request the current entries without changing them, or during a **Set** +operation, to clear every explicit entry from the SACL. Every entry must have a `type` of `audit`. +Each item is an [access control entry object](#access-control-entry-object). #### systemProtected accessControlList @@ -281,30 +280,24 @@ Type : boolean -Defines whether the SACL is protected from inheriting entries from its parent key. Only meaningful -when `system` is also specified. Omitting this property behaves according to `_purge`: when purging -(the default), the SACL is written as unprotected (inheriting), since a purge write always stamps an -explicit protection state; when merging (`_purge: false`), the current protection state is preserved -unchanged. Set this explicitly to request a particular protection state regardless of `_purge`. +Defines whether the SACL is protected from inheriting entries from its parent key. This is +meaningful when `system` is specified or `_purge` is explicitly `true`. For a managed SACL, +omission uses `false` for replacement and preserves the current value for merge. #### _purge accessControlList
Expand for accessControlList._purge subproperty metadata ```yaml -Type : boolean -Default : true +Type : boolean ```
-Defines whether to replace (purge) each supplied section outright, or merge its entries into the -section's current managed entries. Defaults to `true` (replace) when omitted, which is the -historical full-replacement behavior: the `discretionary` and/or `system` entries supplied in a -**Set** operation become the section's entire content. When a section is replaced outright and its -corresponding `discretionaryProtected`/`systemProtected` property isn't specified, the protection -state resets to unprotected (`false`) rather than being left unchanged, since a purge write always -stamps an explicit protection state. +When `_purge` is omitted, each supplied section replaces that section's entire content and each +omitted section remains unmanaged. When `_purge` is explicitly `true`, both sections are replaced: +an omitted `discretionary` or `system` property clears that section. A replaced section's omitted +protection property resets to unprotected (`false`). When `false`, the entries supplied for a section are merged into that section's existing entries instead of replacing them: entries that already exist are left in place, newly supplied entries are @@ -314,13 +307,13 @@ is canonicalized into Windows' canonical access control entry order: every expli then every explicit `allow` entry, then every inherited entry, preserving the inherited entries' existing relative order. In particular, an inherited `deny` entry is never moved ahead of an explicit `allow` entry, since Windows always places every explicit entry before any inherited entry -regardless of allow/deny. A section that isn't supplied in the request remains unmanaged, and its -corresponding protection property (if any) is preserved unchanged, regardless of this setting. +regardless of allow/deny. A section that isn't supplied in a merge request remains unmanaged, and +its corresponding protection property (if any) is preserved unchanged. For a **Test** operation, `_purge: false` is satisfied when every requested entry is present (after normalization) in the actual access control list; extra entries already present in the -actual ACL don't cause a mismatch. `_purge: true` (the default) requires the same entries in the -same order and count as `actual`, but each entry pair is compared semantically: an aliased +actual ACL don't cause a mismatch. Replacement behavior requires the same entries in the same +order and count as `actual`, but each entry pair is compared semantically: an aliased permission name (for example `fullControl` versus the individual rights it implies) or a differently ordered `flags` list that is otherwise equivalent doesn't cause a mismatch. @@ -645,8 +638,7 @@ non validating keywords are omitted. "type": "boolean" }, "_purge": { - "type": "boolean", - "default": true + "type": "boolean" } } }, diff --git a/lib/dsc-lib-registry/locales/en-us.toml b/lib/dsc-lib-registry/locales/en-us.toml index db30118b7..18684eb94 100644 --- a/lib/dsc-lib-registry/locales/en-us.toml +++ b/lib/dsc-lib-registry/locales/en-us.toml @@ -5,7 +5,7 @@ _version = 1 [error] acl = "Registry ACL" -invalidAcl = "At least one of accessControlList.discretionary or accessControlList.system must be specified" +invalidAcl = "At least one of accessControlList.discretionary or accessControlList.system must be specified unless _purge is explicitly true" invalidHive = "Invalid hive" json = "JSON" offlineRegistry = "Offline registry" diff --git a/lib/dsc-lib-registry/src/acl.rs b/lib/dsc-lib-registry/src/acl.rs index 6e5ed7247..64830072a 100644 --- a/lib/dsc-lib-registry/src/acl.rs +++ b/lib/dsc-lib-registry/src/acl.rs @@ -155,11 +155,9 @@ impl Drop for PrivateSecurityDescriptor { } } -/// Validates that `requested` is well-formed: that it specifies at least one of `discretionary` -/// or `system` (an `accessControlList` with neither is never meaningful, since there would be -/// nothing to read, write, or compare), and that every entry is well-formed for the section it -/// belongs to (for example that `audit` entries are only used in the system section). Performs -/// no registry access. +/// Validates that `requested` is well-formed: that it specifies at least one section unless +/// `_purge` is explicitly true (which clears omitted sections), and that every entry is +/// well-formed for the section it belongs to. Performs no registry access. /// /// Called centrally by every operation ([`get`], [`set`] via [`effective`], and the registry /// resource's `test`) before any registry read, mutation, or what-if preview, so that an invalid @@ -169,7 +167,10 @@ impl Drop for PrivateSecurityDescriptor { /// /// Returns [`RegistryError::Acl`] when `requested` is not well-formed. pub fn validate(requested: &RegistryAccessControlList) -> Result<(), RegistryError> { - if requested.discretionary.is_none() && requested.system.is_none() { + if requested.discretionary.is_none() + && requested.system.is_none() + && requested.purge != Some(true) + { return Err(RegistryError::Acl(t!("error.invalidAcl").to_string())); } requested.validate().map_err(acl_error) @@ -196,7 +197,7 @@ pub fn preflight(requested: &RegistryAccessControlList) -> Result<(), RegistryEr /// /// Returns [`RegistryError::Acl`] when the privilege can't be enabled. fn ensure_privilege_for(requested: &RegistryAccessControlList) -> Result<(), RegistryError> { - if requested.system.is_some() { + if requested.manages_system() { enable_security_privilege().map_err(acl_error)?; } Ok(()) @@ -215,8 +216,8 @@ pub fn get( requested: &RegistryAccessControlList, ) -> Result { validate(requested)?; - let want_discretionary = requested.discretionary.is_some(); - let want_system = requested.system.is_some(); + let want_discretionary = requested.manages_discretionary(); + let want_system = requested.manages_system(); let mut descriptor_bytes = read_descriptor(path, requested)?; // SAFETY: descriptor_bytes was populated by RegGetKeySecurity and is a complete, well-formed @@ -231,8 +232,8 @@ fn read_descriptor( ) -> Result, RegistryError> { ensure_privilege_for(requested)?; let key = open(path, read_access(requested))?; - let want_discretionary = requested.discretionary.is_some(); - let want_system = requested.system.is_some(); + let want_discretionary = requested.manages_discretionary(); + let want_system = requested.manages_system(); let security_information = read_security_information(want_discretionary, want_system); let mut size = 0; @@ -326,12 +327,11 @@ pub(crate) fn set_prepared( } /// Computes the effective access control list that should be written for `requested`, honoring -/// its `_purge` setting: when `true` (the default), each supplied section is `requested`'s -/// section (normalized) verbatim, and no registry read is needed to compute it -- a protection -/// flag that is not explicitly supplied resolves to its effective `false` default rather than -/// being left unresolved (see [`merge_access_control_list`]). When `false`, reads `path`'s -/// current state for the section(s) `requested` manages and merges `requested`'s entries into -/// them (deduplicating semantic duplicates and canonicalizing ACE order). +/// its `_purge` setting: omitted `_purge` replaces supplied sections, explicit `true` replaces +/// both sections and clears omitted sections, and neither replacement mode needs a registry read. +/// A protection flag that is not explicitly supplied resolves to its effective `false` default +/// (see [`merge_access_control_list`]). When `false`, reads `path`'s current state for the +/// section(s) `requested` manages and merges `requested`'s entries into them. /// /// # Errors /// @@ -393,8 +393,8 @@ pub fn effective_for_new( let mut current = unsafe { read_access_control_list( &mut inherited_bytes, - normalized.discretionary.is_some(), - normalized.system.is_some(), + normalized.manages_discretionary(), + normalized.manages_system(), ) } .map_err(acl_error)?; @@ -463,10 +463,10 @@ pub fn is_satisfied( fn read_access(requested: &RegistryAccessControlList) -> REG_SAM_FLAGS { let mut access = REG_SAM_FLAGS(0); - if requested.discretionary.is_some() { + if requested.manages_discretionary() { access |= READ_CONTROL; } - if requested.system.is_some() { + if requested.manages_system() { access |= ACCESS_SYSTEM_SECURITY; } access @@ -474,10 +474,10 @@ fn read_access(requested: &RegistryAccessControlList) -> REG_SAM_FLAGS { fn write_access(requested: &RegistryAccessControlList) -> REG_SAM_FLAGS { let mut access = REG_SAM_FLAGS(0); - if requested.discretionary.is_some() { + if requested.manages_discretionary() { access |= WRITE_DAC; } - if requested.system.is_some() { + if requested.manages_system() { access |= ACCESS_SYSTEM_SECURITY; } access @@ -554,6 +554,23 @@ mod tests { assert_eq!(read_access(&requested), READ_CONTROL); } + #[test] + fn explicit_purge_true_requests_access_for_both_sections() { + let requested = RegistryAccessControlList { + purge: Some(true), + ..Default::default() + }; + + assert_eq!( + read_access(&requested), + READ_CONTROL | ACCESS_SYSTEM_SECURITY + ); + assert_eq!( + write_access(&requested), + WRITE_DAC | ACCESS_SYSTEM_SECURITY + ); + } + #[test] fn write_access_includes_system_security_for_sacl() { let mut audit_entry = entry(AceType::Audit); @@ -589,7 +606,7 @@ mod tests { #[test] fn effective_with_purge_true_does_not_require_the_key_to_exist() { - // `_purge: true` (the default) never needs to read the key's current state, so this + // Explicit `_purge: true` never needs to read the key's current state, so this // must succeed even though the key path below does not exist. let requested = RegistryAccessControlList { discretionary: Some(vec![entry(AceType::Allow)]), @@ -644,9 +661,20 @@ mod tests { fn validate_rejects_access_control_list_with_no_sections() { let requested = RegistryAccessControlList::default(); let error = validate(&requested).unwrap_err(); + assert!(matches!(error, RegistryError::Acl(_))); } + #[test] + fn validate_accepts_access_control_list_with_no_sections_when_purge_is_true() { + let requested = RegistryAccessControlList { + purge: Some(true), + ..Default::default() + }; + + validate(&requested).unwrap(); + } + #[test] fn effective_with_purge_true_defaults_protected_to_false_without_reading_current() { let requested = RegistryAccessControlList { @@ -669,6 +697,8 @@ mod tests { let actual = RegistryAccessControlList { discretionary: Some(vec![entry(AceType::Allow)]), discretionary_protected: Some(false), + system: Some(Vec::new()), + system_protected: Some(false), ..Default::default() }; let requested = RegistryAccessControlList { @@ -685,6 +715,8 @@ mod tests { let actual = RegistryAccessControlList { discretionary: Some(vec![entry(AceType::Allow)]), discretionary_protected: Some(false), + system: Some(Vec::new()), + system_protected: Some(false), ..Default::default() }; let mut different_entry = entry(AceType::Allow); diff --git a/lib/dsc-lib-registry/src/lib.rs b/lib/dsc-lib-registry/src/lib.rs index 64d18b5df..a0b7efb97 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -1145,10 +1145,9 @@ fn test_satisfies_purge_true_acl_via_alias_equivalent_access_mask() { // the decomposed list below. A raw per-field/array comparison between the two // representations would (incorrectly) treat this as a mismatch. // - // The principal is the well-known "Everyone" SID rather than, say, LocalSystem: since this - // is a `_purge: true` (the default) write, it replaces the DACL outright with only this one - // entry, and the test process must remain able to read (and ultimately clean up) the key - // afterward. + // The principal is the well-known "Everyone" SID rather than, say, LocalSystem: replacement + // mode writes the DACL with only this one entry, and the test process must remain able to read + // (and ultimately clean up) the key afterward. let decomposed_json = r#"{"keyPath":"HKCU\\DSCTestNativeAlias","accessControlList":{"discretionary":[{"principal":"S-1-1-0","type":"allow","accessMask":["queryValue","setValue","createSubKey","enumerateSubKeys","notify","createLink","delete","readPermissions","changePermissions","takeOwnership"]}],"discretionaryProtected":false}}"#; RegistryHelper::new_from_json(decomposed_json).unwrap().set().unwrap(); @@ -1192,15 +1191,14 @@ fn registry_access_right_generic_variants_have_expected_bits() { } #[test] -fn set_what_if_with_value_name_returns_effective_acl_for_all_purge_modes() { +fn set_what_if_with_value_name_returns_effective_acl_for_omitted_and_false_purge() { // Note the doubled backslash: `key_path` is embedded directly into JSON string literals // below, so it must already be JSON-escaped. let key_path = r"HKCU\\DSCTestNativeWhatIfExisting"; // Create the key (and an initial ACE) first, representing an "existing key" scenario. The - // principal is the well-known "Everyone" SID rather than, say, LocalSystem: this is a - // `_purge: true` (the default) write that replaces the DACL outright with only this one - // entry, and the test process must remain able to read (and ultimately clean up) the key - // afterward. + // principal is the well-known "Everyone" SID rather than, say, LocalSystem: replacement mode + // writes the DACL with only this one entry, and the test process must remain able to read (and + // ultimately clean up) the key afterward. let initial_json = format!( r#"{{"keyPath":"{key_path}","accessControlList":{{"discretionary":[{{"principal":"S-1-1-0","type":"allow","accessMask":["fullControl"]}}]}}}}"# ); @@ -1208,7 +1206,6 @@ fn set_what_if_with_value_name_returns_effective_acl_for_all_purge_modes() { for (purge_property, should_merge) in [ ("", false), - (r#","_purge":true"#, false), (r#","_purge":false"#, true), ] { let what_if_json = format!( @@ -1251,10 +1248,9 @@ fn set_what_if_with_value_name_returns_effective_acl_for_all_purge_modes() { } #[test] -fn set_what_if_for_missing_key_returns_effective_acl_for_all_purge_modes() { +fn set_what_if_for_missing_key_returns_effective_acl_for_omitted_and_false_purge() { for (purge_property, expected_count, expected_protection) in [ ("", Some(3), Some(false)), - (r#","_purge":true"#, Some(3), Some(false)), (r#","_purge":false"#, None, Some(false)), ] { let what_if_json = format!( diff --git a/lib/lib-acl/src/model.rs b/lib/lib-acl/src/model.rs index 95d448501..c2feaf10a 100644 --- a/lib/lib-acl/src/model.rs +++ b/lib/lib-acl/src/model.rs @@ -203,57 +203,43 @@ impl AccessControlEntry

{ /// The access control list (ACL) for an object, expressed as arrays of access control entries. /// -/// Both `discretionary` (DACL) and `system` (SACL) sections are optional so that callers can -/// select which section(s) they want to manage; a section that is omitted is left untouched. +/// Both `discretionary` (DACL) and `system` (SACL) sections are optional. When `_purge` is omitted +/// or `false`, callers select which section(s) to manage and omitted sections are left untouched. +/// Explicit `_purge: true` manages both sections and clears either section that is omitted. /// /// `P` is the resource-specific access mask permission enum (for example the set of rights /// understood by the filesystem or the registry). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AccessControlList

{ - /// The discretionary access control list (DACL) entries. Omit to leave the DACL unmanaged. + /// The discretionary access control list (DACL) entries. Omit to leave the DACL unmanaged + /// unless `_purge` is explicitly `true`, in which case omission clears the DACL. #[serde(skip_serializing_if = "Option::is_none")] pub discretionary: Option>>, - /// Whether the DACL is protected from inheriting entries from its parent. Only meaningful - /// when `discretionary` is also provided; has no effect otherwise. Omitting this property - /// behaves according to `_purge`: when purging (the default), the DACL is written as - /// unprotected (inheriting), since a purge write always stamps an explicit protection state; - /// when merging (`_purge: false`), the current protection state is preserved unchanged, since - /// a merge leaves everything it doesn't explicitly touch alone. Set this explicitly to - /// request a particular protection state regardless of `_purge`. + /// Whether the DACL is protected from inheriting entries from its parent. This is meaningful + /// when `discretionary` is provided or `_purge` is explicitly `true`. When a managed DACL's + /// protection state is omitted, replacement writes use `false` and merge writes preserve the + /// current value. #[serde(skip_serializing_if = "Option::is_none")] pub discretionary_protected: Option, - /// The system access control list (SACL) entries. Omit to leave the SACL unmanaged. + /// The system access control list (SACL) entries. Omit to leave the SACL unmanaged unless + /// `_purge` is explicitly `true`, in which case omission clears the SACL. #[serde(skip_serializing_if = "Option::is_none")] pub system: Option>>, - /// Whether the SACL is protected from inheriting entries from its parent. Only meaningful - /// when `system` is also provided; has no effect otherwise. Omitting this property behaves - /// according to `_purge`: when purging (the default), the SACL is written as unprotected - /// (inheriting), since a purge write always stamps an explicit protection state; when - /// merging (`_purge: false`), the current protection state is preserved unchanged, since a - /// merge leaves everything it doesn't explicitly touch alone. Set this explicitly to request - /// a particular protection state regardless of `_purge`. + /// Whether the SACL is protected from inheriting entries from its parent. This is meaningful + /// when `system` is provided or `_purge` is explicitly `true`. When a managed SACL's + /// protection state is omitted, replacement writes use `false` and merge writes preserve the + /// current value. #[serde(skip_serializing_if = "Option::is_none")] pub system_protected: Option, - /// Whether to replace (purge) each supplied section outright, or merge its entries into the - /// section's current managed entries. Defaults to `true` (replace) when omitted, which - /// preserves the historical full-replacement behavior. When `false`, the entries supplied - /// for a section are merged into that section's existing entries: semantic duplicates (same - /// normalized principal, ACE type, effective access mask, and inheritance/audit flags) are - /// not duplicated, and the resulting discretionary section is canonicalized into Windows' - /// canonical ACE order: every explicit `deny` entry, then every explicit `allow` entry, then - /// every inherited entry (preserving its existing relative order) -- an inherited `deny` - /// entry is never moved ahead of an explicit `allow` entry. A section that is omitted from - /// the request remains unmanaged regardless of this setting. + /// Controls replacement and merge behavior. When omitted, each supplied section is replaced + /// and each omitted section remains unmanaged. Explicit `true` replaces both sections, + /// clearing either section that is omitted. When `false`, supplied entries are merged into + /// their corresponding current sections and omitted sections remain unmanaged. #[serde(rename = "_purge", skip_serializing_if = "Option::is_none")] - #[schemars(default = "default_purge")] pub purge: Option, } -fn default_purge() -> Option { - Some(true) -} - impl

Default for AccessControlList

{ fn default() -> Self { Self { @@ -286,11 +272,23 @@ impl AccessControlList

{ Ok(()) } - /// The effective value of `purge`: `true` (replace) when not explicitly specified. + /// Whether supplied sections use replacement semantics. #[must_use] pub fn purge(&self) -> bool { self.purge.unwrap_or(true) } + + /// Whether the DACL is managed by this request. + #[must_use] + pub fn manages_discretionary(&self) -> bool { + self.discretionary.is_some() || self.purge == Some(true) + } + + /// Whether the SACL is managed by this request. + #[must_use] + pub fn manages_system(&self) -> bool { + self.system.is_some() || self.purge == Some(true) + } } /// A normalized key identifying whether two access control entries are semantic duplicates: @@ -414,9 +412,10 @@ fn canonical_ace_rank(entry: &AccessControlEntry

) -> u8 { /// section(s) `requested` manages. Both `current` and `requested` are expected to already have /// normalized (for example resolved-SID) principals. /// -/// When `purge` is `true` (the default), each supplied section is replaced outright: the result -/// is `requested`'s section verbatim. A protection flag that is not explicitly supplied in -/// `requested` defaults to `false` in this case, matching the real effect of writing a section +/// When `_purge` is omitted, each supplied section is replaced outright and omitted sections are +/// unmanaged. Explicit `_purge: true` also replaces supplied sections and clears omitted sections. +/// A protection flag that is not explicitly supplied in `requested` defaults to `false` for a +/// replacement write, matching the real effect of writing a section /// with no protection state requested (see [`crate::win32::build_self_relative_security_descriptor`]): /// the write always stamps an explicit protected/unprotected state, so a desired-state comparison /// must not treat the omitted flag as "don't care" and silently accept a protected or unprotected @@ -425,54 +424,58 @@ fn canonical_ace_rank(entry: &AccessControlEntry

) -> u8 { /// explicitly supplied in `requested` instead preserves `current`'s value, since a merge leaves /// everything it doesn't explicitly touch alone. /// -/// A section that is not present (`None`) in `requested` is left unmanaged and is always `None` -/// in the result, regardless of `current`. +/// With omitted or false `_purge`, a section absent from `requested` is unmanaged and remains +/// `None` in the result. With explicit `_purge: true`, an absent section becomes an empty list. #[must_use] pub fn merge_access_control_list( current: &AccessControlList

, requested: &AccessControlList

, ) -> AccessControlList

{ let purge = requested.purge(); + let manages_discretionary = requested.manages_discretionary(); + let manages_system = requested.manages_system(); - let discretionary = requested.discretionary.as_ref().map(|entries| { + let discretionary = manages_discretionary.then(|| { + let entries = requested.discretionary.as_deref().unwrap_or(&[]); if purge { - entries.clone() + entries.to_vec() } else { merge_entries(current.discretionary.as_deref().unwrap_or(&[]), entries) } }); - let discretionary_protected = requested.discretionary.as_ref().and_then(|_| { - if purge { - // A purge write always stamps an explicit protection state, so the omitted flag - // resolves to its effective `false` default rather than staying unresolved. - Some(requested.discretionary_protected.unwrap_or(false)) - } else { - // A merge leaves everything it doesn't explicitly touch alone: fall back to - // `current`'s value, which may itself be `None` (unknown) rather than `false`. - requested - .discretionary_protected - .or(current.discretionary_protected) - } - }); + let discretionary_protected = if !manages_discretionary { + None + } else if purge { + // A purge write always stamps an explicit protection state, so the omitted flag + // resolves to its effective `false` default rather than staying unresolved. + Some(requested.discretionary_protected.unwrap_or(false)) + } else { + // A merge leaves everything it doesn't explicitly touch alone: fall back to + // `current`'s value, which may itself be `None` (unknown) rather than `false`. + requested + .discretionary_protected + .or(current.discretionary_protected) + }; - let system = requested.system.as_ref().map(|entries| { + let system = manages_system.then(|| { + let entries = requested.system.as_deref().unwrap_or(&[]); if purge { - entries.clone() + entries.to_vec() } else { merge_entries(current.system.as_deref().unwrap_or(&[]), entries) } }); - let system_protected = requested.system.as_ref().and_then(|_| { - if purge { - // A purge write always stamps an explicit protection state, so the omitted flag - // resolves to its effective `false` default rather than staying unresolved. - Some(requested.system_protected.unwrap_or(false)) - } else { - // A merge leaves everything it doesn't explicitly touch alone: fall back to - // `current`'s value, which may itself be `None` (unknown) rather than `false`. - requested.system_protected.or(current.system_protected) - } - }); + let system_protected = if !manages_system { + None + } else if purge { + // A purge write always stamps an explicit protection state, so the omitted flag + // resolves to its effective `false` default rather than staying unresolved. + Some(requested.system_protected.unwrap_or(false)) + } else { + // A merge leaves everything it doesn't explicitly touch alone: fall back to + // `current`'s value, which may itself be `None` (unknown) rather than `false`. + requested.system_protected.or(current.system_protected) + }; AccessControlList { discretionary, @@ -489,8 +492,8 @@ pub fn merge_access_control_list( /// than only the partial, newly requested entries, and a protection flag omitted from the /// original request has already been resolved to its effective value). /// -/// A section that is `None` in `desired` is always satisfied (it is unmanaged). When `desired`'s -/// `purge` is `true` (the default), a managed section must match `actual` entry-for-entry, in +/// A section that is `None` in `desired` is always satisfied (it is unmanaged). Under replacement +/// semantics, a managed section must match `actual` entry-for-entry, in /// the same order and with the same count and inherited status throughout (since a purge write /// replaces the section outright, verbatim and in the order given) -- but each entry pair is /// compared *semantically* (see [`entries_semantically_equal`]), so an aliased permission name @@ -607,13 +610,36 @@ mod tests { } #[test] - fn purge_true_replaces_the_section_outright() { + fn omitted_purge_replaces_only_the_supplied_section() { let current = AccessControlList { discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + system: Some(vec![entry("S-1-1-1", AceType::Audit, vec![TestRight::Write])]), + system_protected: Some(true), ..Default::default() }; let requested = AccessControlList { - discretionary: Some(vec![entry("S-1-1-1", AceType::Allow, vec![TestRight::Write])]), + discretionary: Some(vec![entry("S-1-1-2", AceType::Allow, vec![TestRight::Write])]), + ..Default::default() + }; + + let effective = merge_access_control_list(¤t, &requested); + + assert_eq!(effective.discretionary, requested.discretionary); + assert_eq!(effective.discretionary_protected, Some(false)); + assert!(effective.system.is_none()); + assert!(effective.system_protected.is_none()); + } + + #[test] + fn purge_true_replaces_supplied_section_and_clears_omitted_section() { + let current = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + system: Some(vec![entry("S-1-1-1", AceType::Audit, vec![TestRight::Write])]), + system_protected: Some(true), + ..Default::default() + }; + let requested = AccessControlList { + discretionary: Some(vec![entry("S-1-1-2", AceType::Allow, vec![TestRight::Write])]), purge: Some(true), ..Default::default() }; @@ -621,12 +647,35 @@ mod tests { let effective = merge_access_control_list(¤t, &requested); assert_eq!(effective.discretionary, requested.discretionary); + assert_eq!(effective.system, Some(Vec::new())); + assert_eq!(effective.system_protected, Some(false)); + } + + #[test] + fn purge_true_with_no_sections_clears_both_sections() { + let current = AccessControlList { + discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + system: Some(vec![entry("S-1-1-1", AceType::Audit, vec![TestRight::Write])]), + ..Default::default() + }; + let requested = AccessControlList { + purge: Some(true), + ..Default::default() + }; + + let effective = merge_access_control_list(¤t, &requested); + + assert_eq!(effective.discretionary, Some(Vec::new())); + assert_eq!(effective.discretionary_protected, Some(false)); + assert_eq!(effective.system, Some(Vec::new())); + assert_eq!(effective.system_protected, Some(false)); } #[test] - fn purge_false_merges_new_entries_with_current_ones() { + fn purge_false_merges_supplied_section_and_leaves_omitted_section_unmanaged() { let current = AccessControlList { discretionary: Some(vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]), + system: Some(vec![entry("S-1-1-2", AceType::Audit, vec![TestRight::Read])]), ..Default::default() }; let requested = AccessControlList { @@ -645,6 +694,8 @@ mod tests { .map(|entry| entry.principal.as_str()) .collect(); assert_eq!(principals, vec!["S-1-1-0", "S-1-1-1"]); + assert!(effective.system.is_none()); + assert!(effective.system_protected.is_none()); } #[test] diff --git a/lib/lib-acl/src/win32/security_descriptor.rs b/lib/lib-acl/src/win32/security_descriptor.rs index 8c4f30936..c352697f4 100644 --- a/lib/lib-acl/src/win32/security_descriptor.rs +++ b/lib/lib-acl/src/win32/security_descriptor.rs @@ -76,13 +76,7 @@ pub unsafe fn read_access_control_list( }, )?; - let mut result = AccessControlList { - // A raw read always reflects the actual, current state of the sections requested; report - // `purge: true` (the default) for consistency, since there is no partial/merge request to - // reflect here. - purge: Some(true), - ..Default::default() - }; + let mut result = AccessControlList::default(); if want_discretionary { // SAFETY: the caller guarantees descriptor_bytes is a valid, self-relative security diff --git a/resources/file_dsc/file_dsc.dsc.manifests.json b/resources/file_dsc/file_dsc.dsc.manifests.json index 85415f805..6a1699062 100644 --- a/resources/file_dsc/file_dsc.dsc.manifests.json +++ b/resources/file_dsc/file_dsc.dsc.manifests.json @@ -185,12 +185,12 @@ "accessControlList": { "type": "object", "title": "Access control list", - "description": "The discretionary (DACL) and/or system (SACL) access control list for the file or folder. Omitting a section leaves it unmanaged; at least one section must be present.", + "description": "The discretionary (DACL) and/or system (SACL) access control list for the file or folder. At least one section must be present unless '_purge' is explicitly true, which clears omitted sections.", "properties": { "discretionary": { "type": "array", "title": "Discretionary access control entries", - "description": "The discretionary access control list (DACL) entries. Omit to leave the DACL unmanaged.", + "description": "The discretionary access control list (DACL) entries. Omit to leave the DACL unmanaged unless '_purge' is explicitly true, in which case omission clears the DACL.", "items": { "$ref": "#/$defs/accessControlEntry" } @@ -198,12 +198,12 @@ "discretionaryProtected": { "type": "boolean", "title": "Discretionary access control list is protected", - "description": "Whether the DACL is protected from inheriting entries from its parent. Only meaningful when 'discretionary' is also provided. Omitting this property behaves according to '_purge': when purging (the default), the DACL is written as unprotected (inheriting), since a purge write always stamps an explicit protection state; when merging ('_purge': false), the current protection state is preserved unchanged. Set this explicitly to request a particular protection state regardless of '_purge'." + "description": "Whether the DACL is protected from inheriting entries from its parent. This is meaningful when 'discretionary' is provided or '_purge' is explicitly true. For a managed DACL, omission uses false for replacement and preserves the current value for merge." }, "system": { "type": "array", "title": "System access control entries", - "description": "The system access control list (SACL) entries. Omit to leave the SACL unmanaged. Reading or writing this section requires the SeSecurityPrivilege privilege.", + "description": "The system access control list (SACL) entries. Omit to leave the SACL unmanaged unless '_purge' is explicitly true, in which case omission clears the SACL. Reading or writing this section requires the SeSecurityPrivilege privilege.", "items": { "$ref": "#/$defs/accessControlEntry" } @@ -211,13 +211,12 @@ "systemProtected": { "type": "boolean", "title": "System access control list is protected", - "description": "Whether the SACL is protected from inheriting entries from its parent. Only meaningful when 'system' is also provided. Omitting this property behaves according to '_purge': when purging (the default), the SACL is written as unprotected (inheriting), since a purge write always stamps an explicit protection state; when merging ('_purge': false), the current protection state is preserved unchanged. Set this explicitly to request a particular protection state regardless of '_purge'." + "description": "Whether the SACL is protected from inheriting entries from its parent. This is meaningful when 'system' is provided or '_purge' is explicitly true. For a managed SACL, omission uses false for replacement and preserves the current value for merge." }, "_purge": { "type": "boolean", - "title": "Purge supplied sections", - "description": "Whether to replace (purge) each supplied section outright, or merge its entries into the section's current managed entries. Defaults to true (replace) when omitted. When false, the entries supplied for a section are merged into that section's existing entries: semantic duplicates (same normalized principal, ACE type, effective access mask, and inheritance/audit flags) are not duplicated, and the resulting discretionary section is canonicalized into Windows' canonical ACE order: every explicit 'deny' entry, then every explicit 'allow' entry, then every inherited entry (preserving its existing relative order) -- an inherited 'deny' entry is never moved ahead of an explicit 'allow' entry. A section that is omitted from the request remains unmanaged regardless of this setting. For a Test operation, '_purge': false is satisfied when every requested entry is present (after normalization) in the actual access control list; extra entries already present in the actual ACL don't cause a mismatch. '_purge': true (the default) requires the same entries in the same order and count as actual, but each entry pair is compared semantically: an aliased permission name (for example 'fullControl' versus the individual rights it implies) or a differently ordered 'flags' list that is otherwise equivalent doesn't cause a mismatch.", - "default": true + "title": "Purge access control lists", + "description": "Controls replacement and merge behavior. When omitted, each supplied section is replaced and each omitted section remains unmanaged. Explicit true replaces both sections, clearing either section that is omitted. When false, supplied entries are merged into their corresponding current sections and omitted sections remain unmanaged." } }, "additionalProperties": false diff --git a/resources/file_dsc/locales/en-us.toml b/resources/file_dsc/locales/en-us.toml index ace05ed2e..73d8e8d84 100644 --- a/resources/file_dsc/locales/en-us.toml +++ b/resources/file_dsc/locales/en-us.toml @@ -29,7 +29,7 @@ missingOperation = "Missing ACL operation. Usage: file_dsc acl -- unknownOperation = "Unknown ACL operation: '%{operation}'. Expected: get, set, or test" windowsOnly = "The filesystem access control list resource is only supported on Windows" missingAccessControlList = "The accessControlList property is required" -invalidAccessControlList = "The accessControlList property must specify a discretionary and/or system section" +invalidAccessControlList = "The accessControlList property must specify a discretionary and/or system section unless _purge is explicitly true" pathNotFound = "The path '%{path}' does not exist" pathCheckError = "Failed to inspect the path '%{path}': %{error}" getError = "Failed to read the access control list for '%{path}': %{error}" diff --git a/resources/file_dsc/src/acl.rs b/resources/file_dsc/src/acl.rs index 520dcc906..b5ab8607b 100644 --- a/resources/file_dsc/src/acl.rs +++ b/resources/file_dsc/src/acl.rs @@ -28,15 +28,14 @@ pub fn get(input: &FileAccessControlList) -> Result Result { let requested = validate_input(input)?; let normalized = normalize(requested).map_err(acl_error)?; - if normalized.system.is_some() { + if normalized.manages_system() { enable_security_privilege().map_err(acl_error)?; } // The Win32 ACL-writing APIs always replace a section outright, so a merge (`_purge: false`) - // is implemented by first computing the full, merged section(s) to write. `purge` mode (the - // default) needs no current-state read, since `merge_access_control_list`'s purge branch - // never consults `current` -- it still resolves an omitted protection flag to its effective - // `false` default, so an empty placeholder stands in for `current` instead. + // is implemented by first computing the full, merged section(s) to write. Replacement mode + // needs no current-state read, since `merge_access_control_list` never consults `current` for + // managed sections and resolves omitted protection flags to their effective `false` default. let current = if normalized.purge() { FileAcl::default() } else { @@ -96,7 +95,10 @@ fn validate_input(input: &FileAccessControlList) -> Result<&FileAcl, String> { let Some(requested) = input.access_control_list.as_ref() else { return Err(t!("acl.missingAccessControlList").to_string()); }; - if requested.discretionary.is_none() && requested.system.is_none() { + if requested.discretionary.is_none() + && requested.system.is_none() + && requested.purge != Some(true) + { return Err(t!("acl.invalidAccessControlList").to_string()); } requested.validate().map_err(acl_error)?; @@ -104,8 +106,8 @@ fn validate_input(input: &FileAccessControlList) -> Result<&FileAcl, String> { } fn read_acl(path: &str, requested: &FileAcl) -> Result { - let want_discretionary = requested.discretionary.is_some(); - let want_system = requested.system.is_some(); + let want_discretionary = requested.manages_discretionary(); + let want_system = requested.manages_system(); if want_system { enable_security_privilege().map_err(acl_error)?; } diff --git a/resources/file_dsc/src/types.rs b/resources/file_dsc/src/types.rs index 5ebc2d6b4..c0174e0a4 100644 --- a/resources/file_dsc/src/types.rs +++ b/resources/file_dsc/src/types.rs @@ -171,8 +171,8 @@ impl AccessMask for FileAccessRight { #[serde(deny_unknown_fields)] pub struct FileAccessControlList { pub path: String, - /// The discretionary and/or system access control list. Omitting a section leaves it - /// unmanaged; at least one section must be present. + /// The discretionary and/or system access control list. At least one section must be present + /// unless `_purge` is explicitly true, which clears omitted sections. #[serde(rename = "accessControlList", skip_serializing_if = "Option::is_none")] pub access_control_list: Option>, #[serde(rename = "_inDesiredState", skip_serializing_if = "Option::is_none")] diff --git a/resources/file_dsc/tests/filesystem_acl.tests.ps1 b/resources/file_dsc/tests/filesystem_acl.tests.ps1 index 40a7fdcd5..799244971 100644 --- a/resources/file_dsc/tests/filesystem_acl.tests.ps1 +++ b/resources/file_dsc/tests/filesystem_acl.tests.ps1 @@ -128,7 +128,6 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { Context '_purge behavior' { It 'What-if returns the resulting ACL without changing the file when _purge is ' -ForEach @( @{ purgeMode = 'omitted'; purge = $null; expectedCount = 1; expectExisting = $false } - @{ purgeMode = 'true'; purge = $true; expectedCount = 1; expectExisting = $false } @{ purgeMode = 'false'; purge = $false; expectedCount = 2; expectExisting = $true } ) { $path = New-Item -ItemType File -Path (Join-Path $testRoot "whatif-$purgeMode.txt") | @@ -171,7 +170,7 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { $actualState.accessControlList.discretionary[0].principal | Should -BeExactly $currentSid } - It 'Replaces the discretionary section outright when _purge is omitted (default true)' { + It 'Replaces the discretionary section outright when _purge is omitted' { $path = New-Item -ItemType File -Path (Join-Path $testRoot 'replace.txt') | Select-Object -ExpandProperty FullName $json = @{ path = $path; accessControlList = (New-DesiredAcl) } | @@ -322,7 +321,7 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { ($testOutput | ConvertFrom-Json).inDesiredState | Should -BeFalse } - It 'Tests true with _purge true using an alias-equivalent access mask' { + It 'Tests true in replacement mode using an alias-equivalent access mask' { $path = New-Item -ItemType File -Path (Join-Path $testRoot 'alias.txt') | Select-Object -ExpandProperty FullName # Individually, these rights are bit-for-bit identical to `fullControl`. @@ -344,7 +343,7 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { # A real system read always reports the most comprehensive alias that fits exactly, # so the actual entry now reports `fullControl`; a Test using the original, - # decomposed list must still report satisfied under `_purge: true` (the default) + # decomposed list must still report satisfied under replacement semantics # instead of comparing the access mask arrays for raw equality. $testOutput = $json | dsc resource test -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) @@ -359,7 +358,7 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { $null = $protectedJson | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) - # Purge (the default) again, without specifying discretionaryProtected this time. + # Replace the DACL again, without specifying discretionaryProtected this time. $unspecifiedAcl = @{ discretionary = (New-DesiredAcl).discretionary } $unspecifiedJson = @{ path = $path; accessControlList = $unspecifiedAcl } | ConvertTo-Json -Depth 10 -Compress @@ -492,6 +491,25 @@ Describe 'Microsoft.Filesystem/AccessControlList tests' -Skip:(!$IsWindows) { } Context 'System access control list (SACL)' -Skip:(!$isElevated) { + It 'What-if with explicit _purge true clears omitted sections without changing the file' { + $path = New-Item -ItemType File -Path (Join-Path $testRoot 'purge-all-whatif.txt') | + Select-Object -ExpandProperty FullName + $before = (Get-Acl -LiteralPath $path).Sddl + $json = @{ + path = $path + accessControlList = @{ _purge = $true } + } | ConvertTo-Json -Depth 10 -Compress + + $whatIfOutput = $json | + dsc resource set -r $resourceType -f - -w 2>$TestDrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $afterState = ($whatIfOutput | ConvertFrom-Json).afterState + + @($afterState.accessControlList.discretionary).Count | Should -Be 0 + @($afterState.accessControlList.system).Count | Should -Be 0 + (Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before + } + It 'Sets and gets Audit ACEs on a file' { $path = New-Item -ItemType File -Path (Join-Path $testRoot 'audit.txt') | Select-Object -ExpandProperty FullName diff --git a/resources/registry/tests/registry.config.set.tests.ps1 b/resources/registry/tests/registry.config.set.tests.ps1 index 6cc3c586b..1aea098b7 100644 --- a/resources/registry/tests/registry.config.set.tests.ps1 +++ b/resources/registry/tests/registry.config.set.tests.ps1 @@ -145,7 +145,7 @@ Describe 'registry config set tests' { $result.accessControlList.discretionaryProtected | Should -BeFalse } - It 'Replaces the discretionary section by default when _purge is omitted' -Skip:(!$IsWindows) { + It 'Replaces the discretionary section when _purge is omitted' -Skip:(!$IsWindows) { # Anonymous Logon's well-known SID. It never matches the test process's own token, so an # Allow/Deny entry for it can't accidentally lock the test process out of the key. $anonymousSid = 'S-1-5-7' @@ -164,7 +164,7 @@ Describe 'registry config set tests' { $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) # The replacement keeps the current user's own entry (so the key stays manageable) but - # omits the anonymous deny entry; with _purge omitted (default true) that entry must go. + # omits the anonymous deny entry; replacement semantics mean that entry must go. $replace = @{ keyPath = 'HKCU\1\AclPurgeDefault' accessControlList = @{ @@ -350,7 +350,7 @@ Describe 'registry config set tests' { registry config set --input $protectedRequest 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) - # Purge (the default) again, this time without specifying discretionaryProtected. + # Replace the DACL again, this time without specifying discretionaryProtected. $unspecifiedRequest = @{ keyPath = 'HKCU\1\AclPurgeProtectedDefault' accessControlList = @{ diff --git a/resources/registry/tests/registry.config.test.tests.ps1 b/resources/registry/tests/registry.config.test.tests.ps1 index 0a16c2862..659c11acb 100644 --- a/resources/registry/tests/registry.config.test.tests.ps1 +++ b/resources/registry/tests/registry.config.test.tests.ps1 @@ -73,7 +73,7 @@ Describe 'registry config test tests' { # A real system read always reports the most comprehensive alias that fits exactly, so # the actual entry now reports `fullControl`; a Test using the original, decomposed list - # must still report satisfied under `_purge: true` (the default) instead of comparing the + # must still report satisfied under replacement semantics instead of comparing the # access mask arrays for raw equality. $result = registry config test --input $json 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) diff --git a/resources/registry/tests/registry.config.whatif.tests.ps1 b/resources/registry/tests/registry.config.whatif.tests.ps1 index 6d10ebb41..28aefb064 100644 --- a/resources/registry/tests/registry.config.whatif.tests.ps1 +++ b/resources/registry/tests/registry.config.whatif.tests.ps1 @@ -2,6 +2,15 @@ # Licensed under the MIT License. Describe 'registry config whatif tests' { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + BeforeAll { Remove-Item -Path 'HKCU:\1' -Recurse -ErrorAction Ignore } @@ -108,6 +117,22 @@ Describe 'registry config whatif tests' { Should -BeExactly ($before.discretionary | ConvertTo-Json -Depth 10 -Compress) } + It 'Returns empty DACL and SACL for explicit _purge true without changing the key' -Skip:(!$isElevated) { + New-Item -Path 'HKCU:\1\AclPurgeAllWhatIf' -Force | Out-Null + $before = (Get-Acl -Path 'HKCU:\1\AclPurgeAllWhatIf').Sddl + $json = @{ + keyPath = 'HKCU\1\AclPurgeAllWhatIf' + accessControlList = @{ _purge = $true } + } | ConvertTo-Json -Depth 10 -Compress + + $result = registry config set -w --input $json 2>$TestDrive/error.log | ConvertFrom-Json + + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + @($result.accessControlList.discretionary).Count | Should -Be 0 + @($result.accessControlList.system).Count | Should -Be 0 + (Get-Acl -Path 'HKCU:\1\AclPurgeAllWhatIf').Sddl | Should -BeExactly $before + } + It 'Can whatif setting a value and merging an ACL on an existing key, returning the effective merged ACL' -Skip:(!$IsWindows) { New-Item -Path 'HKCU:\1\AclWhatIfValueName' -Force | Out-Null $initial = @{