diff --git a/Cargo.lock b/Cargo.lock index 31ec37535..4f246178b 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 = [ + "rust-i18n", + "schemars", + "serde", + "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..43b3c4be3 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: ``` @@ -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 @@ -108,7 +111,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. @@ -116,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 @@ -165,12 +171,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 +185,161 @@ 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 supplied sections, purge both sections, + or merge supplied sections. + +#### 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 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 + +
Expand for accessControlList.discretionaryProtected subproperty metadata + +```yaml +Type : boolean +``` + +
+ +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 + +
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 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 + +
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. 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 +``` + +
+ +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 +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 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 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. 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. + +#### 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`, `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`. +- **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 @@ -327,6 +481,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 @@ -390,6 +561,10 @@ non validating keywords are omitted. "type": "boolean", "default": true }, + "_inDesiredState": { + "type": ["boolean", "null"], + "readOnly": true + }, "_metadata": { "type": "object", "readOnly": true, @@ -403,8 +578,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", "delete", "readPermissions", + "changePermissions", "takeOwnership", "synchronize", "read", "write", + "execute", "fullControl", "genericRead", "genericWrite", "genericExecute", + "genericAll" + ] + } + }, + "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" + } + } }, "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..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 = "The SDDL must define an owner, group, or DACL" +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" @@ -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..64830072a 100644 --- a/lib/dsc-lib-registry/src/acl.rs +++ b/lib/dsc-lib-registry/src/acl.rs @@ -3,33 +3,54 @@ 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 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 windows::Win32::Security::Authorization::{ - ConvertSecurityDescriptorToStringSecurityDescriptorW, - ConvertStringSecurityDescriptorToSecurityDescriptorW, +use lib_acl::merge_access_control_list; +use windows::Win32::Foundation::{ + CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, ERROR_PATH_NOT_FOUND, + ERROR_SUCCESS, HANDLE, }; 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, + 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::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::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +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; + +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); @@ -42,78 +63,262 @@ impl Drop for RegistryKey { } } -struct LocalAllocation(HLOCAL); +struct ProcessToken(HANDLE); -impl Drop for LocalAllocation { +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: SDDL conversion APIs allocate this pointer with LocalAlloc. + // SAFETY: This handle was returned by OpenProcessToken and is owned by this wrapper. unsafe { - let _ = LocalFree(Some(self.0)); + let _ = CloseHandle(self.0); } } } -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; +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 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 +/// `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() + && requested.purge != Some(true) + { + return Err(RegistryError::Acl(t!("error.invalidAcl").to_string())); + } + 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 +/// -- 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.manages_system() { + 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. +/// +/// # 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 { + validate(requested)?; + 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 + // 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.manages_discretionary(); + let want_system = requested.manages_system(); + 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()))?; + Ok(descriptor_bytes) +} + +/// Returns whether `path` currently exists and could accept `requested` being applied via +/// [`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), + Err(error) => Err(error), } - 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 } -pub fn validate(sddl: &str) -> Result<(), RegistryError> { - SecurityDescriptor::from_sddl(sddl)?.permissions(false)?; - Ok(()) +/// 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> { + validate(requested)?; + ensure_privilege_for(requested)?; + + let mut prepared = prepare(path, requested)?; + set_prepared(path, &mut prepared) } -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)?; +/// 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, + }) +} - // SAFETY: The descriptor was returned by the SDDL conversion API and remains allocated for the - // duration of this call. +/// 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.descriptor) }; + unsafe { RegSetKeySecurity(key.0, prepared.security_information, descriptor) }; if status != ERROR_SUCCESS { return Err(win32_error("RegSetKeySecurity", status.0)); } @@ -121,124 +326,168 @@ 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: 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 +/// +/// 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() { + // `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)); } -} -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(); +/// 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)?; + if normalized.purge() { + return Ok(merge_access_control_list( + &RegistryAccessControlList::default(), + &normalized, + )); + } - // 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 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()))?; + 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.manages_discretionary(), + normalized.manages_system(), + ) + } + .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); } + } - 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(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), } - Ok((information, REG_SAM_FLAGS(access))) } + + Err(RegistryError::RegistryKeyNotFound(path.to_string())) } -fn open( - path: &str, - access: windows::Win32::System::Registry::REG_SAM_FLAGS, -) -> Result { +/// 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.manages_discretionary() { + access |= READ_CONTROL; + } + if requested.manages_system() { + access |= ACCESS_SYSTEM_SECURITY; + } + access +} + +fn write_access(requested: &RegistryAccessControlList) -> REG_SAM_FLAGS { + let mut access = REG_SAM_FLAGS(0); + if requested.manages_discretionary() { + access |= WRITE_DAC; + } + if requested.manages_system() { + access |= ACCESS_SYSTEM_SECURITY; + } + access +} + +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 +526,295 @@ fn win32_error(operation: &str, code: u32) -> RegistryError { #[cfg(test)] 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 { + principal: "S-1-5-18".to_string(), + ace_type, + access_mask: vec![RegistryAccessRight::Read], + flags: Vec::new(), + audit_flags: Vec::new(), + } + } + + #[test] + 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_detects_requested_sections() { - let descriptor = SecurityDescriptor::from_sddl("O:SYG:BAD:(A;;KA;;;SY)").unwrap(); - let (information, access) = descriptor.permissions(false).unwrap(); + fn explicit_purge_true_requests_access_for_both_sections() { + let requested = RegistryAccessControlList { + purge: Some(true), + ..Default::default() + }; assert_eq!( - information.0, - OWNER_SECURITY_INFORMATION.0 - | GROUP_SECURITY_INFORMATION.0 - | DACL_SECURITY_INFORMATION.0 + read_access(&requested), + READ_CONTROL | ACCESS_SYSTEM_SECURITY + ); + assert_eq!( + write_access(&requested), + WRITE_DAC | ACCESS_SYSTEM_SECURITY ); - assert_eq!(access.0, 0x000c_0000); } #[test] - fn permissions_preserves_dacl_protection() { - let descriptor = SecurityDescriptor::from_sddl("D:P(A;;KA;;;SY)").unwrap(); - let (information, access) = descriptor.permissions(true).unwrap(); + 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); + } - assert_eq!( - information, - DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION - ); - assert_eq!(access.0, 0x0004_0000); + #[test] + 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 validate_accepts_well_formed_discretionary_entry() { + let requested = RegistryAccessControlList { + discretionary: Some(vec![entry(AceType::Allow)]), + ..Default::default() + }; + validate(&requested).unwrap(); + } + + #[test] + fn effective_with_purge_true_does_not_require_the_key_to_exist() { + // 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)]), + 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 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(); + 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_eq!(information, DACL_SECURITY_INFORMATION); - assert_eq!(access.0, 0x0004_0000); + 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); } #[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_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_rejects_invalid_sddl() { - let error = validate("D:not-valid").unwrap_err(); + fn validate_accepts_access_control_list_with_no_sections_when_purge_is_true() { + let requested = RegistryAccessControlList { + purge: Some(true), + ..Default::default() + }; - assert!(matches!(error, RegistryError::Acl(_))); + validate(&requested).unwrap(); + } + + #[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), + system: Some(Vec::new()), + system_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), + system: Some(Vec::new()), + system_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()); + } + + 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] + #[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; + } + + 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 { + system: Some(vec![audit_entry]), + ..Default::default() + }; + + let can_set_result = can_set(&key_path, &requested); + let _ = helper.remove(); + + assert!(matches!(can_set_result, Ok(true)), "{can_set_result:?}"); } } diff --git a/lib/dsc-lib-registry/src/config.rs b/lib/dsc-lib-registry/src/config.rs index 9ec3f5683..c0150b3f9 100644 --- a/lib/dsc-lib-registry/src/config.rs +++ b/lib/dsc-lib-registry/src/config.rs @@ -1,9 +1,128 @@ // 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, + /// `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, + /// `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 { + 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::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, + Self::GenericExecute => 0x2000_0000, + Self::GenericWrite => 0x4000_0000, + Self::GenericRead => 0x8000_0000, + Self::GenericAll => 0x1000_0000, + } + } + + fn all() -> &'static [Self] { + &[ + Self::QueryValue, + Self::SetValue, + Self::CreateSubKey, + Self::EnumerateSubKeys, + Self::Notify, + Self::CreateLink, + Self::Delete, + Self::ReadPermissions, + Self::ChangePermissions, + Self::TakeOwnership, + Self::Synchronize, + Self::Read, + Self::Write, + Self::Execute, + Self::FullControl, + Self::GenericRead, + Self::GenericWrite, + Self::GenericExecute, + 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)] pub enum RegistryValueData { String(String), @@ -30,20 +149,23 @@ 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 /// 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)] #[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..a0b7efb97 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -106,10 +106,11 @@ 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, + in_desired_state: None, }; Ok( Self { @@ -141,12 +142,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 +159,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 +178,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 +191,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 +203,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 +257,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 { - acl::validate(acl)?; + if let Some(acl) = &self.config.access_control_list { + acl::preflight(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() })); @@ -317,6 +319,23 @@ 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, + }; + 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(|requested| acl::effective_for_new(&self.config.key_path, requested)) + .transpose()? + } else { + None + }; if let Some(value_name) = &self.config.value_name { let value_data = match &self.config.value_data { @@ -366,7 +385,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 +394,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: what_if_acl, metadata: if what_if_metadata.is_empty() { None } else { Some(Metadata { what_if: Some(what_if_metadata) })}, ..Default::default() })); @@ -387,26 +406,83 @@ 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: what_if_acl, 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 { - 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) } + /// 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 @@ -510,7 +586,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(), )); @@ -560,6 +636,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(); @@ -1001,3 +1102,251 @@ 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: 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(); + + 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_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: 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"]}}]}}}}"# + ); + RegistryHelper::new_from_json(&initial_json).unwrap().set().unwrap(); + + for (purge_property, should_merge) in [ + ("", 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!( + 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_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":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}}}}}"# + ); + 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(); + + 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 + ); + } + } +} + +#[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(); + + assert_eq!(acl.discretionary_protected, Some(false)); + assert_eq!( + acl.discretionary.unwrap()[0].principal, + "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/.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..4e841003a --- /dev/null +++ b/lib/lib-acl/Cargo.toml @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +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 } + +[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 new file mode 100644 index 000000000..1d0bb4c49 --- /dev/null +++ b/lib/lib-acl/src/error.rs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use std::fmt::{Display, Formatter}; + +/// Errors that can occur while modeling or applying an access control list. +#[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. + InvalidEntry(String), + + /// The principal (SID string or account name) could not be resolved to a security + /// identifier. + PrincipalResolution { principal: String, reason: String }, + + /// A value that was expected to be a SID string was not formatted correctly. + InvalidSid(String), + + /// A Win32 API call failed. + Win32 { operation: &'static str, code: u32 }, + + /// The current process does not hold a privilege required to manage the system access + /// control list (SACL). + 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. + 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. + 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. + 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 new file mode 100644 index 000000000..04f9059b9 --- /dev/null +++ b/lib/lib-acl/src/lib.rs @@ -0,0 +1,23 @@ +// 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. + +rust_i18n::i18n!("locales", fallback = "en-us"); + +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..c2feaf10a --- /dev/null +++ b/lib/lib-acl/src/model.rs @@ -0,0 +1,1029 @@ +// 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 rust_i18n::t; +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]; + + /// 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. +#[must_use] +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. +/// +/// 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 Ok(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; + } + } + if remaining != 0 { + return Err(AclError::UnrepresentableAccessMask(remaining)); + } + Ok(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( + t!("validation.discretionaryType").to_string(), + )); + } + if !self.audit_flags.is_empty() { + return Err(AclError::InvalidEntry( + t!("validation.discretionaryAuditFlags").to_string(), + )); + } + if self.access_mask.is_empty() { + return Err(AclError::InvalidEntry( + t!("validation.accessMaskRequired").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( + t!("validation.systemType").to_string(), + )); + } + if self.audit_flags.is_empty() { + return Err(AclError::InvalidEntry( + t!("validation.systemAuditFlags").to_string(), + )); + } + if self.access_mask.is_empty() { + return Err(AclError::InvalidEntry( + t!("validation.accessMaskRequired").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. 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 + /// 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. 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 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. 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, + /// 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")] + pub purge: Option, +} + +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(()) + } + + /// 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: +/// 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, 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, + ace_type: AceType, + mask_bits: u32, + flags: Vec, + audit_flags: Vec, +} + +/// 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 +} + +/// 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: effective_mask_bits(&entry.access_mask), + 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 + && 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) +} + +/// 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 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( + 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 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 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 +/// `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. +/// +/// 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 = manages_discretionary.then(|| { + let entries = requested.discretionary.as_deref().unwrap_or(&[]); + if purge { + entries.to_vec() + } else { + merge_entries(current.discretionary.as_deref().unwrap_or(&[]), entries) + } + }); + 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 = manages_system.then(|| { + let entries = requested.system.as_deref().unwrap_or(&[]); + if purge { + entries.to_vec() + } else { + merge_entries(current.system.as_deref().unwrap_or(&[]), entries) + } + }); + 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, + 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, 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). 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 +/// (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. +#[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 { + 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(); + 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, + GenericAll, + } + + 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, + Self::GenericAll => 0x100, + } + } + + fn all() -> &'static [Self] { + &[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() + } + } + } + + 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 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-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() + }; + + 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_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 { + 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"]); + assert!(effective.system.is_none()); + assert!(effective.system_protected.is_none()); + } + + #[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_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]); + inherited.flags = vec![AceFlag::Inherited]; + let current = vec![inherited]; + let requested = vec![entry("S-1-1-0", AceType::Allow, vec![TestRight::Read])]; + + let merged = merge_entries(¤t, &requested); + + assert_eq!(merged.len(), 2); + assert!(!merged[0].flags.contains(&AceFlag::Inherited)); + assert!(merged[1].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)); + } + + #[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 new file mode 100644 index 000000000..0da4b3f2b --- /dev/null +++ b/lib/lib-acl/src/win32/acl.rs @@ -0,0 +1,254 @@ +// 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, + } + }) +} + +/// 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 { + 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. +/// +/// # 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) unsafe fn parse_acl( + acl_ptr: *mut ACL, +) -> Result>, AclError> { + let mut size_info = ACL_SIZE_INFORMATION::default(); + // 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, + (&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)); + }; + + // 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)?; + + 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..c352697f4 --- /dev/null +++ b/lib/lib-acl/src/win32/security_descriptor.rs @@ -0,0 +1,411 @@ +// 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. +/// +/// # 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, or +/// [`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, + 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: 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", + code: error.code().0 as u32, + }, + )?; + + let mut result = AccessControlList::default(); + + if want_discretionary { + // 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 { + // SAFETY: see above. + let (entries, protected) = unsafe { read_acl_section(descriptor, control, true) }?; + result.system = Some(entries); + result.system_protected = Some(protected); + } + + Ok(result) +} + +/// # 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, +) -> 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: 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 { + 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, + })?; + + // 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() { + 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/ + // Sacl just populated from it) is valid. + let entries = unsafe { parse_acl::

(acl_ptr) }?; + + 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)?; + // 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() + }) + }) + .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())); + } + + #[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/lib/lib-acl/src/win32/sid.rs b/lib/lib-acl/src/win32/sid.rs new file mode 100644 index 000000000..cfe7f2b7f --- /dev/null +++ b/lib/lib-acl/src/win32/sid.rs @@ -0,0 +1,255 @@ +// 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 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}; +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 principal.contains('\0') { + return Err(AclError::PrincipalResolution { + principal: principal.to_string(), + reason: t!("principal.nulCharacter").to_string(), + }); + } + 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`). +/// +/// # 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 unsafe fn sid_to_string(sid: PSID) -> Result { + let mut pwstr = PWSTR::null(); + // 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, + })?; + + // 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 +} + +/// 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 { + 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 { + 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: t!("principal.notFound").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)) +} + +#[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")); + } + + #[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/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..6a1699062 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", @@ -128,10 +128,14 @@ { "jsonInputArg": "--input", "mandatory": true + }, + { + "whatIfArg": "--what-if" } ], "implementsPretest": false, - "return": "state" + "return": "state", + "whatIfReturns": "state" }, "test": { "executable": "file_dsc", @@ -149,17 +153,17 @@ "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" + "accessControlList" ], "properties": { "path": { @@ -169,11 +173,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 +182,135 @@ }, "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. 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 unless '_purge' is explicitly true, in which case omission clears the DACL.", + "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. 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 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" + } + }, + "systemProtected": { + "type": "boolean", + "title": "System access control list is protected", + "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 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 + }, + "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. 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", + "enum": [ + "readData", + "writeData", + "appendData", + "readExtendedAttributes", + "writeExtendedAttributes", + "executeFile", + "deleteChild", + "readAttributes", + "writeAttributes", + "delete", + "readPermissions", + "changePermissions", + "takeOwnership", + "synchronize", + "read", + "write", + "readAndExecute", + "modify", + "fullControl", + "genericRead", + "genericWrite", + "genericExecute", + "genericAll" + ] + } + }, + "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..73d8e8d84 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 unless _purge is explicitly true" 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..b5ab8607b 100644 --- a/resources/file_dsc/src/acl.rs +++ b/resources/file_dsc/src/acl.rs @@ -1,78 +1,82 @@ // 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()?; +pub fn set(input: &FileAccessControlList, what_if: bool) -> Result { + let requested = validate_input(input)?; + let normalized = normalize(requested).map_err(acl_error)?; + if normalized.manages_system() { + enable_security_privilege().map_err(acl_error)?; + } - unsafe { - SetFileSecurityW( - PCWSTR(path.as_ptr()), - security_information, - security_descriptor.as_ptr(), - ) + // 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. 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 { + read_acl(&input.path, &normalized)? + }; + let to_write = merge_access_control_list(¤t, &normalized); + + if what_if { + return Ok(FileAccessControlList { + path: input.path.clone(), + access_control_list: Some(to_write), + in_desired_state: None, + }); } - .ok() - .map_err(|error| { - t!( - "acl.setError", - path = input.path.as_str(), - error = error.to_string() - ) - .to_string() - })?; + + 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 +91,98 @@ 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() + && requested.purge != Some(true) + { + 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.manages_discretionary(); + let want_system = requested.manages_system(); + 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() - })?; + .map_err(|error| t!("acl.getError", path = path, error = error.to_string()).to_string())?; - SecurityDescriptor::borrowed(buffer.as_mut_ptr().cast()).to_sddl() + // 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) } -fn to_wide(value: &str) -> Vec { - value.encode_utf16().chain(std::iter::once(0)).collect() +/// 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()) } -struct SecurityDescriptor { - pointer: PSECURITY_DESCRIPTOR, - owned: bool, +fn acl_error(error: AclError) -> String { + error.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 - } -} - -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..f2cafec88 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; @@ -73,12 +73,13 @@ fn handle_acl(args: &[String]) -> 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")) } } @@ -118,7 +119,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..c0174e0a4 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,161 @@ 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, + /// `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 { + 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, + Self::GenericExecute => 0x2000_0000, + Self::GenericWrite => 0x4000_0000, + Self::GenericRead => 0x8000_0000, + Self::GenericAll => 0x1000_0000, + } + } + + 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, + Self::GenericRead, + Self::GenericWrite, + Self::GenericExecute, + 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. #[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. 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")] 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..799244971 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,352 @@ 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 'What-if returns the resulting ACL without changing the file when _purge is ' -ForEach @( + @{ purgeMode = 'omitted'; purge = $null; 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' { + $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 '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`. + $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 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) + ($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) + + # Replace the DACL 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' + + $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 + } + } + + + 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 'must contain only a discretionary ACL' + 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 "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 +465,84 @@ 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' } + + 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 '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 + $acl = @{ + system = @( + @{ + principal = $currentSid + type = 'audit' + accessMask = @('read', 'write') + auditFlags = @('success', 'failure') + } + ) + } + $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 + + $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/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.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..1aea098b7 100644 --- a/resources/registry/tests/registry.config.set.tests.ps1 +++ b/resources/registry/tests/registry.config.set.tests.ps1 @@ -100,40 +100,278 @@ 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.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' + + $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) + + $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 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; replacement semantics mean 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) - $result.acl | Should -BeExactly $desiredAcl - $registryAcl = Get-Acl -Path 'HKCU:\1\AclSet' - $registryAcl.SetAccessRuleProtection($false, $true) - $desiredAcl = $registryAcl.GetSecurityDescriptorSddlForm( - [System.Security.AccessControl.AccessControlSections]::Access) + # 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 'Rejects an access control list request that specifies neither discretionary nor system' -Skip:(!$IsWindows) { $json = @{ - keyPath = 'HKCU\1\AclSet' - acl = $desiredAcl - } | ConvertTo-Json -Compress + 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) + + # Replace the DACL 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) - (Get-Acl -Path 'HKCU:\1\AclSet').AreAccessRulesProtected | Should -BeFalse + + $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) { @@ -232,4 +470,137 @@ 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'" + } + + 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 + Set-ItemProperty -Path $keyPath -Name 'Data' -Value 'original' + $missingAccount = "Everyone$([char]0)DSC-Invalid" + $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' + } + + 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 = Get-Acl -Path $keyPath + $security.SetSecurityDescriptorSddlForm( + "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' + 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' + } } 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..659c11acb --- /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 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) + $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 14025fb16..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 } @@ -72,24 +81,120 @@ 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 '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 = @{ + 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 resulting ACL' -Skip:(!$IsWindows) { + $whatIfJson = @{ + keyPath = 'HKCU\1\AclWhatIfMissingKey' + valueName = 'Hello' + valueData = @{ String = 'World' } + accessControlList = @{ + discretionary = @( + @{ 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) + + $result.accessControlList.discretionary.Count | Should -Be 1 + $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) { 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) } }