diff --git a/dsc/tests/dsc_resource_securitycontext.tests.ps1 b/dsc/tests/dsc_resource_securitycontext.tests.ps1 new file mode 100644 index 000000000..cbbe3daa7 --- /dev/null +++ b/dsc/tests/dsc_resource_securitycontext.tests.ps1 @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Tests for resource manifest security context' { + BeforeAll { + $isAdmin = if ($IsWindows) { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + [System.Security.Principal.WindowsPrincipal]::new($identity).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + [System.Environment]::UserName -eq 'root' + } + } + + It 'Resource with security context for operation ' -TestCases @( + # since `set` and `test` rely on `get` to retrieve the current state, we need to always allow that + # and have a separate resource to test the elevated and restricted contexts for get + @{ securityContext = 'Elevated'; operation = 'get'; property = 'actualState'; type = 'Test/SecurityContextElevatedGet' }, + @{ securityContext = 'Elevated'; operation = 'set'; property = 'afterState' }, + @{ securityContext = 'Elevated'; operation = 'delete' }, + @{ securityContext = 'Elevated'; operation = 'test'; property = 'actualState' }, + @{ securityContext = 'Elevated'; operation = 'export' }, + @{ securityContext = 'Restricted'; operation = 'get'; property = 'actualState'; type = 'Test/SecurityContextRestrictedGet' }, + @{ securityContext = 'Restricted'; operation = 'set'; property = 'afterState' }, + @{ securityContext = 'Restricted'; operation = 'delete' }, + @{ securityContext = 'Restricted'; operation = 'test'; property = 'actualState' }, + @{ securityContext = 'Restricted'; operation = 'export' }, + @{ securityContext = 'Current'; operation = 'get'; property = 'actualState' }, + @{ securityContext = 'Current'; operation = 'set'; property = 'afterState' }, + @{ securityContext = 'Current'; operation = 'delete' }, + @{ securityContext = 'Current'; operation = 'test'; property = 'actualState' }, + @{ securityContext = 'Current'; operation = 'export' } + ) { + param($securityContext, $operation, $property, $type) + + if ($null -eq $type) { + $type = "Test/SecurityContext$securityContext" + } + $inputObj = @{ + hello = "world" + action = $operation + } + $out = dsc resource $operation -r $type --input ($inputObj | ConvertTo-Json -Compress) 2>$testdrive/error.log + switch ($securityContext) { + 'Elevated' { + if ($isAdmin) { + $LASTEXITCODE | Should -Be 0 + if ($property) { + $result = $out | ConvertFrom-Json + $result.$property.action | Should -Be $operation + } elseif ($operation -eq 'export') { + $result = $out | ConvertFrom-Json + $result.resources.properties.action | Should -Be 'export' + } + } + else { + $LASTEXITCODE | Should -Be 2 + (Get-Content "$testdrive/error.log") | Should -BeLike "*ERROR*Operation '$operation' for resource '$type' requires security context '$securityContext'*" + } + } + 'Restricted' { + if ($isAdmin) { + $LASTEXITCODE | Should -Be 2 + (Get-Content "$testdrive/error.log") | Should -BeLike "*ERROR*Operation '$operation' for resource '$type' requires security context '$securityContext'*" + } + else { + $LASTEXITCODE | Should -Be 0 + if ($property) { + $result = $out | ConvertFrom-Json + $result.$property.action | Should -Be $operation + } elseif ($operation -eq 'export') { + $result = $out | ConvertFrom-Json + $result.resources.properties.action | Should -Be 'export' + } + } + } + 'Current' { + $LASTEXITCODE | Should -Be 0 + if ($property) { + $result = $out | ConvertFrom-Json + $result.$property.action | Should -Be $operation + } elseif ($operation -eq 'export') { + $result = $out | ConvertFrom-Json + $result.resources.properties.action | Should -Be 'export' + } + } + } + } +} diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 2ae1938ce..ed130823d 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -191,6 +191,7 @@ inDesiredStateNotBool = "'_inDesiredState' is not a boolean" exportNotSupportedUsingGet = "Export is not supported by resource '%{resource}' using get operation" runProcessError = "Failed to run process '%{executable}': %{error}" whatIfWarning = "Resource '%{resource}' uses deprecated 'whatIf' operation. See https://github.com/PowerShell/DSC/issues/1361 for migration information." +securityContextRequired = "Operation '%{operation}' for resource '%{resource}' requires security context '%{context}'" [dscresources.dscresource] invokeGet = "Invoking get for '%{resource}'" diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index 7dadc2ea8..634418caa 100644 --- a/lib/dsc-lib/src/dscresources/command_resource.rs +++ b/lib/dsc-lib/src/dscresources/command_resource.rs @@ -2,12 +2,13 @@ // Licensed under the MIT License. use clap::ValueEnum; +use dsc_lib_security_context::{SecurityContext, get_security_context}; use jsonschema::Validator; use rust_i18n::t; use serde::Deserialize; use serde_json::{Map, Value}; use std::{collections::HashMap, env, path::{Path, PathBuf}, process::Stdio}; -use crate::{configure::{config_doc::ExecutionKind, config_result::{ResourceGetResult, ResourceTestResult}}, dscresources::resource_manifest::SchemaArgKind, types::{ExitCodesMap, FullyQualifiedTypeName}, util::canonicalize_which}; +use crate::{configure::{config_doc::{ExecutionKind, SecurityContextKind}, config_result::{ResourceGetResult, ResourceTestResult}}, dscresources::resource_manifest::SchemaArgKind, types::{ExitCodesMap, FullyQualifiedTypeName}, util::canonicalize_which}; use crate::dscerror::DscError; use super::{ dscresource::{get_diff, redact, DscResource}, @@ -53,6 +54,7 @@ pub fn invoke_get(resource: &DscResource, filter: &str, target_resource: Option< Some(r) => r.type_name.clone(), None => resource.type_name.clone(), }; + validate_security_context(&get.require_security_context, &resource_type, "get")?; let path = if let Some(target_resource) = target_resource { Some(target_resource.path.clone()) } else { @@ -156,6 +158,7 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, execut let Some(set) = set_method.as_ref() else { return Err(DscError::NotImplemented("set".to_string())); }; + validate_security_context(&set.require_security_context, &resource_type, "set")?; verify_json_from_manifest(&resource, desired, target_resource)?; // if resource doesn't implement a pre-test, we execute test first to see if a set is needed @@ -200,6 +203,7 @@ pub fn invoke_set(resource: &DscResource, desired: &str, skip_test: bool, execut Some(r) => r.type_name.clone(), None => resource.type_name.clone(), }; + validate_security_context(&get.require_security_context, &resource_type, "get")?; let path = if let Some(target_resource) = target_resource { Some(target_resource.path.clone()) } else { @@ -357,6 +361,7 @@ pub fn invoke_test(resource: &DscResource, expected: &str, target_resource: Opti Some(r) => r.type_name.clone(), None => resource.type_name.clone(), }; + validate_security_context(&test.require_security_context, &resource_type, "test")?; let path = if let Some(target_resource) = target_resource { Some(target_resource.path.clone()) } else { @@ -517,6 +522,7 @@ pub fn invoke_delete(resource: &DscResource, filter: &str, target_resource: Opti Some(r) => r.type_name.clone(), None => resource.type_name.clone(), }; + validate_security_context(&delete.require_security_context, &resource_type, "delete")?; let path = if let Some(target_resource) = target_resource { Some(target_resource.path.clone()) } else { @@ -675,6 +681,7 @@ pub fn invoke_export(resource: &DscResource, input: Option<&str>, target_resourc Some(r) => r.type_name.clone(), None => resource.type_name.clone(), }; + validate_security_context(&export.require_security_context, &resource_type, "export")?; let path = if let Some(target_resource) = target_resource { Some(target_resource.path.clone()) } else { @@ -1245,6 +1252,25 @@ pub fn log_stderr_line<'a>(process_id: &u32, trace_line: &'a str) -> &'a str "" } +fn validate_security_context(required_security_context: &Option, resource_type: &str, operation: &str) -> Result<(), DscError> { + match required_security_context { + Some(SecurityContextKind::Elevated) => { + if get_security_context() != SecurityContext::Admin { + return Err(DscError::SecurityContext(t!("dscresources.commandResource.securityContextRequired", operation = operation, resource = resource_type, context = "elevated").to_string())); + } + }, + Some(SecurityContextKind::Restricted) => { + if get_security_context() != SecurityContext::User { + return Err(DscError::SecurityContext(t!("dscresources.commandResource.securityContextRequired", operation = operation, resource = resource_type, context = "restricted").to_string())); + } + }, + None | Some(SecurityContextKind::Current) => { + // no specific context required, so allow any context + }, + } + Ok(()) +} + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, ValueEnum)] pub enum TraceLevel { #[serde(rename = "ERROR")] diff --git a/lib/dsc-lib/src/dscresources/resource_manifest.rs b/lib/dsc-lib/src/dscresources/resource_manifest.rs index 886c789f0..34497b67b 100644 --- a/lib/dsc-lib/src/dscresources/resource_manifest.rs +++ b/lib/dsc-lib/src/dscresources/resource_manifest.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::{ + configure::config_doc::SecurityContextKind, schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}, types::{ExitCodesMap, FullyQualifiedTypeName, TagList}, }; @@ -221,6 +222,9 @@ pub struct GetMethod { /// How to pass optional input for a Get. #[serde(skip_serializing_if = "Option::is_none")] pub input: Option, + /// The security context required to run the Get method. Default if not specified is `current`. + #[serde(rename = "requireSecurityContext", skip_serializing_if = "Option::is_none")] + pub require_security_context: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] @@ -241,6 +245,9 @@ pub struct SetMethod { /// The type of return value expected from the Set method. #[serde(rename = "return", skip_serializing_if = "Option::is_none")] pub returns: Option, + /// The security context required to run the Set method. Default if not specified is `current`. + #[serde(rename = "requireSecurityContext", skip_serializing_if = "Option::is_none")] + pub require_security_context: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] @@ -255,6 +262,9 @@ pub struct TestMethod { /// The type of return value expected from the Test method. #[serde(rename = "return", skip_serializing_if = "Option::is_none")] pub returns: Option, + /// The security context required to run the Test method. Default if not specified is `current`. + #[serde(rename = "requireSecurityContext", skip_serializing_if = "Option::is_none")] + pub require_security_context: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] @@ -266,6 +276,9 @@ pub struct DeleteMethod { pub args: Option>, /// How to pass required input for a Delete. pub input: Option, + /// The security context required to run the Delete method. Default if not specified is `current`. + #[serde(rename = "requireSecurityContext", skip_serializing_if = "Option::is_none")] + pub require_security_context: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] @@ -288,6 +301,9 @@ pub struct ExportMethod { pub args: Option>, /// How to pass input for a Export. pub input: Option, + /// The security context required to run the Export method. Default if not specified is `current`. + #[serde(rename = "requireSecurityContext", skip_serializing_if = "Option::is_none")] + pub require_security_context: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] diff --git a/tools/dsctest/dsctest.dsc.manifests.json b/tools/dsctest/dsctest.dsc.manifests.json index 1b02577a8..6b278e4bb 100644 --- a/tools/dsctest/dsctest.dsc.manifests.json +++ b/tools/dsctest/dsctest.dsc.manifests.json @@ -466,6 +466,285 @@ } } }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/SecurityContextCurrent", + "version": "0.1.0", + "get": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "get", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "current" + }, + "set": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "set", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "current" + }, + "test": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "trace", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "current" + }, + "delete": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "delete", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "current" + }, + "export": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "export", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "current" + }, + "schema": { + "command": { + "executable": "dsctest", + "args": [ + "schema", + "-s", + "operation" + ] + } + } + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/SecurityContextElevated", + "version": "0.1.0", + "get": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "get", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "current" + }, + "set": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "set", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "elevated" + }, + "test": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "trace", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "elevated" + }, + "delete": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "delete", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "elevated" + }, + "export": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "export", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "elevated" + }, + "schema": { + "command": { + "executable": "dsctest", + "args": [ + "schema", + "-s", + "operation" + ] + } + } + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/SecurityContextElevatedGet", + "version": "0.1.0", + "get": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "get", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "elevated" + }, + "schema": { + "command": { + "executable": "dsctest", + "args": [ + "schema", + "-s", + "operation" + ] + } + } + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/SecurityContextRestricted", + "version": "0.1.0", + "get": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "get", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "current" + }, + "set": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "set", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "restricted" + }, + "test": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "trace", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "restricted" + }, + "delete": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "delete", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "restricted" + }, + "export": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "export", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "restricted" + }, + "schema": { + "command": { + "executable": "dsctest", + "args": [ + "schema", + "-s", + "operation" + ] + } + } + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/SecurityContextRestrictedGet", + "version": "0.1.0", + "get": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "get", + { + "jsonInputArg": "--input" + } + ], + "requireSecurityContext": "restricted" + }, + "schema": { + "command": { + "executable": "dsctest", + "args": [ + "schema", + "-s", + "operation" + ] + } + } + }, { "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", "type": "Test/Sleep",