diff --git a/Cargo.lock b/Cargo.lock index 463a32b57..910a25f57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,7 +942,7 @@ dependencies = [ [[package]] name = "dsc-lib-registry" -version = "1.2.0" +version = "1.3.0" dependencies = [ "cc", "crossterm", @@ -955,6 +955,7 @@ dependencies = [ "tracing", "tracing-subscriber", "utfx", + "windows 0.62.2", ] [[package]] @@ -1005,7 +1006,7 @@ dependencies = [ [[package]] name = "dsc-resource-registry" -version = "1.2.0" +version = "1.3.0" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 5504aa256..067a4e3bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -290,7 +290,7 @@ urlencoding = { version = "2.1" } which = { version = "8.0.6" } # dsc-lib ipnetwork = { version = "0.21" } -# WindowsUpdate, windows_service, dsc-lib (ACL checks) +# WindowsUpdate, windows_service, dsc-lib (ACL checks), dsc-lib-registry windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_NetworkManagement_WindowsFirewall", @@ -299,6 +299,7 @@ windows = { version = "0.62", features = [ "Win32_System_Com", "Win32_System_Memory", "Win32_System_Ole", + "Win32_System_Registry", "Win32_System_Services", "Win32_System_Variant", "Win32_System_UpdateAgent", diff --git a/docs/reference/resources/Microsoft/Windows/Registry/index.md b/docs/reference/resources/Microsoft/Windows/Registry/index.md index c59e6c087..5343ccd81 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: 03/25/2025 +ms.date: 10/13/2026 ms.topic: reference title: Microsoft.Windows/Registry --- @@ -35,6 +35,7 @@ resources: keyPath: string # Instance properties _exist: + acl: valueData: valueName: ``` @@ -46,6 +47,7 @@ values. The resource can: - Add and remove registry keys. - Add, update, and remove registry values. +- Retrieve and configure registry key access control lists (ACLs). > [!NOTE] > This resource is installed with DSC itself on Windows systems. @@ -106,6 +108,7 @@ 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). - [valueData](#valuedata) - The data for a registry value. - [valueName](#valuename) - The name of the registry value. @@ -162,6 +165,28 @@ 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 + +
Expand for acl property metadata + +```yaml +Type : string +IsRequired : false +IsKey : false +IsReadOnly : false +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. + +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. + ### valueData
Expand for valueData property metadata @@ -378,6 +403,9 @@ non validating keywords are omitted. } } }, + "acl": { + "type": "string" + }, "keyPath": { "type": "string", "pattern": "^(HKCR|HKEY_CLASSES_ROOT|HKCU|HKEY_CURRENT_USER|HKLM|HKEY_LOCAL_MACHINE|HKU|HKEY_USERS|HKCC|HKEY_CURRENT_CONFIG)\\\\" diff --git a/lib/dsc-lib-registry/Cargo.toml b/lib/dsc-lib-registry/Cargo.toml index 0f9981d12..2d21387d7 100644 --- a/lib/dsc-lib-registry/Cargo.toml +++ b/lib/dsc-lib-registry/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dsc-lib-registry" -version = "1.2.0" +version = "1.3.0" edition = "2024" [package.metadata.i18n] @@ -22,6 +22,7 @@ thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } utfx = { workspace = true } +windows = { workspace = true } [build-dependencies] cc = { workspace = true } diff --git a/lib/dsc-lib-registry/locales/en-us.toml b/lib/dsc-lib-registry/locales/en-us.toml index afffeb9af..55baa305f 100644 --- a/lib/dsc-lib-registry/locales/en-us.toml +++ b/lib/dsc-lib-registry/locales/en-us.toml @@ -4,19 +4,25 @@ _version = 1 # No localizable strings [error] +acl = "Registry ACL" +invalidAcl = "The SDDL must define an owner, group, or DACL" invalidHive = "Invalid hive" json = "JSON" offlineRegistry = "Offline registry" +offlineAcl = "ACL operations are not supported for offline registries" offlineValueEnumeration = "Value enumeration is not supported for offline registries" 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}" [registry_helper] whatIfCreateKey = "Key '%{subkey}' not found, would create it" +whatIfSetAcl = "Would set ACL on key '%{key_path}'" whatIfDeleteValue = "Would delete value '%{value_name}'" whatIfDeleteSubkey = "Would delete subkey '%{subkey_name}'" whatIfDeleteNonexistingKey = "Key '%{subkey}' not found, would do nothing" diff --git a/lib/dsc-lib-registry/src/acl.rs b/lib/dsc-lib-registry/src/acl.rs new file mode 100644 index 000000000..197d9e49f --- /dev/null +++ b/lib/dsc-lib-registry/src/acl.rs @@ -0,0 +1,332 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use core::ffi::c_void; + +use windows::Win32::Foundation::{ + ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, ERROR_PATH_NOT_FOUND, ERROR_SUCCESS, HLOCAL, + LocalFree, +}; +use windows::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, + ConvertStringSecurityDescriptorToSecurityDescriptorW, +}; +use windows::Win32::Security::{ + ACL, DACL_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, GetSecurityDescriptorControl, + GetSecurityDescriptorDacl, GetSecurityDescriptorGroup, GetSecurityDescriptorOwner, + GetSecurityDescriptorSacl, OBJECT_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED, + UNPROTECTED_DACL_SECURITY_INFORMATION, +}; +use windows::Win32::System::Registry::{ + HKEY, HKEY_CLASSES_ROOT, HKEY_CURRENT_CONFIG, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, + HKEY_USERS, REG_SAM_FLAGS, RegCloseKey, RegGetKeySecurity, RegOpenKeyExW, + RegSetKeySecurity, +}; +use windows::core::{BOOL, HSTRING, PCWSTR, PWSTR}; + +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); + +struct RegistryKey(HKEY); + +impl Drop for RegistryKey { + fn drop(&mut self) { + // SAFETY: This handle was returned by RegOpenKeyExW and is owned by this wrapper. + unsafe { + let _ = RegCloseKey(self.0); + } + } +} + +struct LocalAllocation(HLOCAL); + +impl Drop for LocalAllocation { + fn drop(&mut self) { + // SAFETY: SDDL conversion APIs allocate this pointer with LocalAlloc. + unsafe { + let _ = LocalFree(Some(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; + + // 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, + ) + }; + if status != ERROR_SUCCESS { + return Err(win32_error("RegGetKeySecurity", status.0)); + } + + let mut sddl = PWSTR::null(); + // SAFETY: The security descriptor was populated by RegGetKeySecurity. The returned string is + // released by LocalAllocation. + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + security_descriptor, + SDDL_REVISION_1, + security_information, + &mut sddl, + None, + ) + .map_err(|error| RegistryError::Acl(error.to_string()))?; + } + let allocation = LocalAllocation(HLOCAL(sddl.0.cast::())); + // SAFETY: The conversion API returned a null-terminated UTF-16 string. + let result = unsafe { sddl.to_string() }.map_err(|error| RegistryError::Acl(error.to_string())); + drop(allocation); + result +} + +pub fn validate(sddl: &str) -> Result<(), RegistryError> { + SecurityDescriptor::from_sddl(sddl)?.permissions(false)?; + Ok(()) +} + +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)?; + + // SAFETY: The descriptor was returned by the SDDL conversion API and remains allocated for the + // duration of this call. + let status = + unsafe { RegSetKeySecurity(key.0, security_information, descriptor.descriptor) }; + if status != ERROR_SUCCESS { + return Err(win32_error("RegSetKeySecurity", status.0)); + } + + 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), + } +} + +struct SecurityDescriptor { + descriptor: PSECURITY_DESCRIPTOR, + _allocation: LocalAllocation, +} + +impl SecurityDescriptor { + fn from_sddl(sddl: &str) -> Result { + let sddl = HSTRING::from(sddl); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + + // SAFETY: The input is a valid null-terminated HSTRING. The returned descriptor is owned by + // the LocalAllocation field. + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR(sddl.as_ptr()), + SDDL_REVISION_1, + &mut descriptor, + None, + ) + .map_err(|error| RegistryError::Acl(error.to_string()))?; + } + Ok(Self { + descriptor, + _allocation: LocalAllocation(HLOCAL(descriptor.0)), + }) + } + + fn permissions( + &self, + include_dacl_protection: bool, + ) -> Result<(OBJECT_SECURITY_INFORMATION, REG_SAM_FLAGS), RegistryError> { + let mut information = OBJECT_SECURITY_INFORMATION(0); + let mut access = 0; + let mut defaulted = BOOL::default(); + let mut owner = PSID::default(); + let mut group = PSID::default(); + let mut dacl = std::ptr::null_mut::(); + let mut sacl = std::ptr::null_mut::(); + let mut dacl_present = BOOL::default(); + let mut sacl_present = BOOL::default(); + + // SAFETY: The descriptor is valid for the lifetime of self, and all output pointers are + // writable local variables. + unsafe { + GetSecurityDescriptorOwner(self.descriptor, &mut owner, &mut defaulted) + .map_err(|error| RegistryError::Acl(error.to_string()))?; + GetSecurityDescriptorGroup(self.descriptor, &mut group, &mut defaulted) + .map_err(|error| RegistryError::Acl(error.to_string()))?; + GetSecurityDescriptorDacl( + self.descriptor, + &mut dacl_present, + &mut dacl, + &mut defaulted, + ) + .map_err(|error| RegistryError::Acl(error.to_string()))?; + GetSecurityDescriptorSacl( + self.descriptor, + &mut sacl_present, + &mut sacl, + &mut defaulted, + ) + .map_err(|error| RegistryError::Acl(error.to_string()))?; + } + + if sacl_present.as_bool() { + return Err(RegistryError::Acl(t!("error.saclUnsupported").to_string())); + } + if !owner.0.is_null() { + information |= OWNER_SECURITY_INFORMATION; + access |= 0x0008_0000; // WRITE_OWNER + } + if !group.0.is_null() { + information |= GROUP_SECURITY_INFORMATION; + access |= 0x0008_0000; // WRITE_OWNER + } + if dacl_present.as_bool() { + information |= DACL_SECURITY_INFORMATION; + access |= 0x0004_0000; // WRITE_DAC + if include_dacl_protection { + let mut control = 0_u16; + let mut revision = 0_u32; + // SAFETY: The descriptor is valid and both output pointers are writable. + unsafe { + GetSecurityDescriptorControl( + self.descriptor, + &mut control, + &mut revision, + ) + .map_err(|error| RegistryError::Acl(error.to_string()))?; + } + if control & SE_DACL_PROTECTED.0 != 0 { + information |= PROTECTED_DACL_SECURITY_INFORMATION; + } else { + information |= UNPROTECTED_DACL_SECURITY_INFORMATION; + } + } + } + if information == OBJECT_SECURITY_INFORMATION(0) { + return Err(RegistryError::Acl(t!("error.invalidAcl").to_string())); + } + Ok((information, REG_SAM_FLAGS(access))) + } +} + +fn open( + path: &str, + access: windows::Win32::System::Registry::REG_SAM_FLAGS, +) -> Result { + let (root, subkey) = split_path(path)?; + let subkey = HSTRING::from(subkey); + let mut key = HKEY::default(); + + // SAFETY: root is a predefined HKEY, subkey is a null-terminated HSTRING, and key is writable. + let status = unsafe { RegOpenKeyExW(root, PCWSTR(subkey.as_ptr()), Some(0), access, &mut key) }; + if status == ERROR_FILE_NOT_FOUND || status == ERROR_PATH_NOT_FOUND { + return Err(RegistryError::RegistryKeyNotFound(path.to_string())); + } + if status != ERROR_SUCCESS { + return Err(win32_error("RegOpenKeyExW", status.0)); + } + + Ok(RegistryKey(key)) +} + +fn split_path(path: &str) -> Result<(HKEY, &str), RegistryError> { + let (hive, subkey) = path + .split_once('\\') + .map_or((path, ""), |(hive, subkey)| (hive, subkey)); + let root = match hive { + "HKCC" | "HKEY_CURRENT_CONFIG" => HKEY_CURRENT_CONFIG, + "HKCR" | "HKEY_CLASSES_ROOT" => HKEY_CLASSES_ROOT, + "HKCU" | "HKEY_CURRENT_USER" => HKEY_CURRENT_USER, + "HKLM" | "HKEY_LOCAL_MACHINE" => HKEY_LOCAL_MACHINE, + "HKU" | "HKEY_USERS" => HKEY_USERS, + _ => return Err(RegistryError::InvalidHive(hive.to_string())), + }; + Ok((root, subkey)) +} + +fn win32_error(operation: &str, code: u32) -> RegistryError { + RegistryError::Acl(t!("error.win32", operation = operation, code = code).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permissions_detects_requested_sections() { + let descriptor = SecurityDescriptor::from_sddl("O:SYG:BAD:(A;;KA;;;SY)").unwrap(); + let (information, access) = descriptor.permissions(false).unwrap(); + + assert_eq!( + information.0, + OWNER_SECURITY_INFORMATION.0 + | GROUP_SECURITY_INFORMATION.0 + | DACL_SECURITY_INFORMATION.0 + ); + assert_eq!(access.0, 0x000c_0000); + } + + #[test] + fn permissions_preserves_dacl_protection() { + let descriptor = SecurityDescriptor::from_sddl("D:P(A;;KA;;;SY)").unwrap(); + let (information, access) = descriptor.permissions(true).unwrap(); + + assert_eq!( + information, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION + ); + assert_eq!(access.0, 0x0004_0000); + } + + #[test] + fn permissions_ignores_component_markers_inside_conditional_aces() { + let descriptor = + SecurityDescriptor::from_sddl(r#"D:(XA;;KR;;;WD;(@User.Title == "O:operator"))"#) + .unwrap(); + let (information, access) = descriptor.permissions(false).unwrap(); + + assert_eq!(information, DACL_SECURITY_INFORMATION); + assert_eq!(access.0, 0x0004_0000); + } + + #[test] + fn permissions_rejects_sacl() { + let descriptor = SecurityDescriptor::from_sddl("S:(AU;SA;KA;;;WD)").unwrap(); + let error = descriptor.permissions(false).unwrap_err(); + + assert!(matches!(error, RegistryError::Acl(_))); + } + + #[test] + fn validate_rejects_invalid_sddl() { + let error = validate("D:not-valid").unwrap_err(); + + assert!(matches!(error, RegistryError::Acl(_))); + } +} diff --git a/lib/dsc-lib-registry/src/config.rs b/lib/dsc-lib-registry/src/config.rs index e988c3fe2..9ec3f5683 100644 --- a/lib/dsc-lib-registry/src/config.rs +++ b/lib/dsc-lib-registry/src/config.rs @@ -30,6 +30,9 @@ pub struct Registry { /// The data of the registry value. #[serde(rename = "valueData", skip_serializing_if = "Option::is_none")] pub value_data: Option, + /// The registry key access control list in Security Descriptor Definition Language (SDDL). + #[serde(rename = "acl", skip_serializing_if = "Option::is_none")] + pub acl: Option, #[serde(rename = "_exist", skip_serializing_if = "Option::is_none")] pub exist: Option, /// Optional path to an offline registry hive file. When specified, operations diff --git a/lib/dsc-lib-registry/src/error.rs b/lib/dsc-lib-registry/src/error.rs index b33884ed8..dfdcf974e 100644 --- a/lib/dsc-lib-registry/src/error.rs +++ b/lib/dsc-lib-registry/src/error.rs @@ -7,6 +7,9 @@ use thiserror::Error; #[derive(Error, Debug)] #[allow(clippy::module_name_repetitions)] pub enum RegistryError { + #[error("{t}: {0}", t = t!("error.acl"))] + Acl(String), + #[error("{t}: {0}", t = t!("error.invalidHive"))] InvalidHive(String), diff --git a/lib/dsc-lib-registry/src/lib.rs b/lib/dsc-lib-registry/src/lib.rs index f62bc830e..7ac41ac7d 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -12,6 +12,7 @@ rust_i18n::i18n!("locales", fallback = "en-us"); pub mod error; pub mod config; pub mod offreg; +mod acl; use std::ffi::OsStr; use std::os::windows::ffi::OsStrExt; @@ -105,6 +106,7 @@ impl RegistryHelper { key_path: key_path.to_string(), value_name, value_data, + acl: None, metadata: None, exist: None, registry_file_path: None, @@ -138,6 +140,28 @@ impl RegistryHelper { return self.get_offline(); } + if self.config.value_name.is_none() + && let Some(requested_acl) = &self.config.acl + { + 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), + ..Default::default() + }), + Err(RegistryError::RegistryKeyNotFound(_)) => Ok(Registry { + key_path: self.config.key_path.clone(), + exist: Some(false), + ..Default::default() + }), + Err(error) => Err(error), + }; + } + + if let Some(requested_acl) = &self.config.acl { + acl::validate(requested_acl)?; + } + let exist: bool; let (reg_key, _subkey) = match self.open(Security::Read) { Ok((reg_key, subkey)) => { @@ -153,6 +177,10 @@ impl RegistryHelper { }, Err(e) => return Err(e), }; + let acl = match &self.config.acl { + Some(requested_acl) => Some(acl::get(&self.config.key_path, requested_acl)?), + None => None, + }; if let Some(value_name) = &self.config.value_name { let value = match reg_key.value(value_name) { @@ -162,6 +190,7 @@ impl RegistryHelper { return Ok(Registry { key_path: self.config.key_path.clone(), value_name: Some(value_name.clone()), + acl, exist: Some(exist), ..Default::default() }); @@ -173,11 +202,13 @@ impl RegistryHelper { key_path: self.config.key_path.clone(), value_name: Some(value_name.clone()), value_data: convert_reg_value(&value)?, + acl, ..Default::default() }) } else { Ok(Registry { key_path: self.config.key_path.clone(), + acl, ..Default::default() }) } @@ -225,10 +256,37 @@ 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() { + return Err(RegistryError::OfflineRegistry( + t!("error.offlineAcl").to_string(), + )); + } return self.set_offline(); } + if let Some(acl) = &self.config.acl { + acl::validate(acl)?; + } let mut what_if_metadata: Vec = Vec::new(); + if self.config.value_name.is_none() + && let Some(config_acl) = &self.config.acl + && 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(), + ); + return Ok(Some(Registry { + key_path: self.config.key_path.clone(), + acl: Some(config_acl.clone()), + metadata: Some(Metadata { what_if: Some(what_if_metadata) }), + ..Default::default() + })); + } + acl::set(&self.config.key_path, config_acl)?; + return Ok(None); + } + let reg_key = match self.open(Security::Write) { Ok((reg_key, _subkey)) => Some(reg_key), // handle NotFound error @@ -308,10 +366,16 @@ impl RegistryHelper { }; if self.what_if { + if self.config.acl.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(), value_data: convert_reg_value(&data)?, value_name: self.config.value_name.clone(), + acl: self.config.acl.clone(), metadata: if what_if_metadata.is_empty() { None } else { Some(Metadata { what_if: Some(what_if_metadata) })}, ..Default::default() })); @@ -323,13 +387,23 @@ impl RegistryHelper { } if self.what_if { + if self.config.acl.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(), 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)?; + } + Ok(None) } @@ -436,6 +510,11 @@ impl RegistryHelper { // --- Offline registry implementations --- fn get_offline(&self) -> Result { + if self.config.acl.is_some() { + return Err(RegistryError::OfflineRegistry( + t!("error.offlineAcl").to_string(), + )); + } let hive = self.offline_hive.as_ref().unwrap(); // Check if the subkey exists diff --git a/resources/registry/Cargo.toml b/resources/registry/Cargo.toml index ad7267719..83a0bb78c 100644 --- a/resources/registry/Cargo.toml +++ b/resources/registry/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dsc-resource-registry" -version = "1.2.0" +version = "1.3.0" edition = "2024" [[bin]] diff --git a/resources/registry/tests/registry.config.get.tests.ps1 b/resources/registry/tests/registry.config.get.tests.ps1 index 1f335ce92..83e94f1e0 100644 --- a/resources/registry/tests/registry.config.get.tests.ps1 +++ b/resources/registry/tests/registry.config.get.tests.ps1 @@ -2,6 +2,18 @@ # Licensed under the MIT License. Describe 'Registry config get tests' { + BeforeAll { + if ($IsWindows) { + New-Item -Path 'HKCU:\1\AclGet' -Force | Out-Null + } + } + + AfterAll { + if ($IsWindows) { + Remove-Item -Path 'HKCU:\1' -Recurse -ErrorAction Ignore + } + } + It 'Can get a registry key' -Skip:(!$IsWindows) { $json = @' { @@ -31,6 +43,31 @@ Describe 'Registry config get tests' { ($result.psobject.properties | Measure-Object).Count | Should -Be 3 } + It 'Can get a registry key ACL' -Skip:(!$IsWindows) { + $json = @{ + keyPath = 'HKCU\1\AclGet' + acl = 'D:' + } | ConvertTo-Json -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:' + } + + It 'Rejects an invalid ACL when the registry key does not exist' -Skip:(!$IsWindows) { + $json = @{ + keyPath = 'HKCU\1\MissingAclGet' + acl = 'invalid' + } | ConvertTo-Json -Compress + + registry config get --input $json 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Not -Be 0 + (Get-Content -Path $TestDrive/error.log -Raw) | Should -Match 'Registry ACL' + } + It 'Traces should be JSON' -Skip:(!$IsWindows) { # keyPath should return Access Denied $json = @' diff --git a/resources/registry/tests/registry.config.set.tests.ps1 b/resources/registry/tests/registry.config.set.tests.ps1 index 7b6cb404c..c08251ab1 100644 --- a/resources/registry/tests/registry.config.set.tests.ps1 +++ b/resources/registry/tests/registry.config.set.tests.ps1 @@ -100,6 +100,42 @@ Describe 'registry config set tests' { Remove-Item -Path 'HKCU:\1' -Recurse -ErrorAction Ignore } + It 'Can set and retrieve a registry key ACL' -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 = @{ + keyPath = 'HKCU\1\AclSet' + acl = $desiredAcl + } | ConvertTo-Json -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 + $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) + $json = @{ + keyPath = 'HKCU\1\AclSet' + acl = $desiredAcl + } | ConvertTo-Json -Compress + + registry config set --input $json 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.log -Raw) + (Get-Acl -Path 'HKCU:\1\AclSet').AreAccessRulesProtected | Should -BeFalse + } + It 'Should succeed when _exist is false and value does not exist' -Skip:(!$IsWindows) { $config = @{ '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' diff --git a/resources/registry/tests/registry.config.whatif.tests.ps1 b/resources/registry/tests/registry.config.whatif.tests.ps1 index 42a0c09af..14025fb16 100644 --- a/resources/registry/tests/registry.config.whatif.tests.ps1 +++ b/resources/registry/tests/registry.config.whatif.tests.ps1 @@ -72,6 +72,26 @@ 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) { + 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) + $json = @{ + keyPath = 'HKCU\1\AclWhatIf' + acl = $desiredAcl + } | ConvertTo-Json -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._metadata.whatIf | Should -Contain "Would set ACL on key 'HKCU\1\AclWhatIf'" + (Get-Acl -Path 'HKCU:\1\AclWhatIf').Sddl | Should -BeExactly $before + } + It 'Can whatif an existing deeply nested key and value' -Skip:(!$IsWindows) { $set_json = @' { diff --git a/resources/registry/tests/registrylist_get.tests.ps1 b/resources/registry/tests/registrylist_get.tests.ps1 index 07b4cf3cf..7f6f8e0c3 100644 --- a/resources/registry/tests/registrylist_get.tests.ps1 +++ b/resources/registry/tests/registrylist_get.tests.ps1 @@ -2,6 +2,18 @@ # Licensed under the MIT License. Describe 'Registry list get tests' { + BeforeAll { + if ($IsWindows) { + New-Item -Path 'HKCU:\1\AclListGet' -Force | Out-Null + } + } + + AfterAll { + if ($IsWindows) { + Remove-Item -Path 'HKCU:\1' -Recurse -ErrorAction Ignore + } + } + It 'Can get a registry list' -Skip:(!$IsWindows) { $config_yaml = @' $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json @@ -35,4 +47,20 @@ Describe 'Registry list get tests' { $out.results.result[0].actualState.registryEntries[3].valueName | Should -BeExactly 'NonExistentValue' $out.results.result[0].actualState.registryEntries[3]._exist | Should -BeFalse } + + It 'Can get ACLs in a registry list' -Skip:(!$IsWindows) { + $json = @{ + registryEntries = @( + @{ + keyPath = 'HKCU\1\AclListGet' + acl = 'D:' + } + ) + } | ConvertTo-Json -Depth 5 -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:' + } } diff --git a/resources/registry/tests/registrylist_set.tests.ps1 b/resources/registry/tests/registrylist_set.tests.ps1 index 5161e109d..f42592752 100644 --- a/resources/registry/tests/registrylist_set.tests.ps1 +++ b/resources/registry/tests/registrylist_set.tests.ps1 @@ -54,4 +54,27 @@ Describe 'Registry list set tests' -Skip:(!$IsWindows) { $out.results.result[0].afterState.registryEntries[3].valueData | Should -BeNullOrEmpty (Get-ItemProperty -Path 'HKCU:\1\2\6' -Name 'Hello4' -ErrorAction Ignore).Hello4 | Should -BeNullOrEmpty } + + It 'Can set ACLs 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 + } + ) + } | ConvertTo-Json -Depth 5 -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 + } } diff --git a/resources/registry/tests/registrylist_whatif.tests.ps1 b/resources/registry/tests/registrylist_whatif.tests.ps1 index 5bc6737ca..6c551df36 100644 --- a/resources/registry/tests/registrylist_whatif.tests.ps1 +++ b/resources/registry/tests/registrylist_whatif.tests.ps1 @@ -74,4 +74,30 @@ Describe 'Registry list set what-if tests' -Skip:(!$IsWindows) { $out.results.result[0].afterState.registryEntries[3]._metadata.whatIf[0] | Should -Match "Would delete value 'Hello4'" $out.results.result[0].afterState.registryEntries[3].valueName | Should -BeExactly 'Hello4' } + + It 'Can whatif setting ACLs 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) + $json = @{ + registryEntries = @( + @{ + keyPath = 'HKCU\1\AclListWhatIf' + acl = $desiredAcl + } + ) + } | ConvertTo-Json -Depth 5 -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]._metadata.whatIf | + Should -Contain "Would set ACL on key 'HKCU\1\AclListWhatIf'" + (Get-Acl -Path 'HKCU:\1\AclListWhatIf').Sddl | Should -BeExactly $before + } }