use crate::error::RegentError;
use crate::hosts::managed_host::InternalApiCallOutcome;
use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
use crate::hosts::properties::{HostProperties, InitSystem, LinuxFlavor, LinuxSpecifics, OsKind};
use crate::secrets::SecretProvidersPool;
use crate::state::Check;
use crate::state::attribute::HostHandler;
use crate::state::attribute::Privilege;
use crate::state::attribute::Remediation;
use crate::state::attribute::RemediationsList;
use crate::state::compliance::AttributeComplianceAssessment;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum UserExpectedState {
Absent {
remove_home: Option<bool>,
},
#[serde(rename_all = "PascalCase")]
Present { details: UserDetails },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "PascalCase")]
pub struct UserDetails {
uid: Option<u32>,
group: Option<String>,
groups: Option<Vec<String>>,
append: Option<bool>,
shell: Option<String>,
home: Option<String>,
comment: Option<String>,
password: Option<String>,
system: Option<bool>,
create_home: Option<bool>,
}
impl UserDetails {
pub fn default() -> Self {
Self {
uid: None,
group: None,
groups: None,
append: None,
shell: None,
home: None,
comment: None,
password: None,
system: None,
create_home: None,
}
}
pub fn with_uid(&mut self, uid: u32) -> &mut Self {
self.uid = Some(uid);
self
}
pub fn with_group(&mut self, group: &str) -> &mut Self {
self.group = Some(group.to_string());
self
}
pub fn with_groups(&mut self, groups: Vec<String>) -> &mut Self {
self.groups = Some(groups);
self
}
pub fn with_append(&mut self, append: bool) -> &mut Self {
self.append = Some(append);
self
}
pub fn with_shell(&mut self, shell: &str) -> &mut Self {
self.shell = Some(shell.to_string());
self
}
pub fn with_home(&mut self, home: &str) -> &mut Self {
self.home = Some(home.to_string());
self
}
pub fn with_comment(&mut self, comment: &str) -> &mut Self {
self.comment = Some(comment.to_string());
self
}
pub fn with_password(&mut self, password: &str) -> &mut Self {
self.password = Some(password.to_string());
self
}
pub fn with_system(&mut self, system: bool) -> &mut Self {
self.system = Some(system);
self
}
pub fn with_create_home(&mut self, create_home: bool) -> &mut Self {
self.create_home = Some(create_home);
self
}
pub fn finish(&mut self) -> Self {
self.clone()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "PascalCase")]
pub struct UserBlockExpectedState {
name: String,
state: UserExpectedState,
}
impl Timeout for UserBlockExpectedState {
fn default_timeout(&self) -> Duration {
Duration::from_secs(5)
}
}
impl UserBlockExpectedState {
pub fn absent(&mut self, username: &str, remove_home: Option<bool>) -> UserBlockExpectedState {
UserBlockExpectedState {
name: username.to_string(),
state: UserExpectedState::Absent { remove_home },
}
}
pub fn present_with_defaults(username: &str) -> UserBlockExpectedState {
UserBlockExpectedState {
name: username.to_string(),
state: UserExpectedState::Present {
details: UserDetails::default(),
},
}
}
pub fn present_with_details(username: &str, details: UserDetails) -> UserBlockExpectedState {
UserBlockExpectedState {
name: username.to_string(),
state: UserExpectedState::Present { details },
}
}
}
impl Check for UserBlockExpectedState {
fn check(&self) -> Result<(), RegentError> {
Ok(())
}
fn check_host_compatibility(
&self,
host_properties: &HostProperties,
) -> Result<(), RegentError> {
match host_properties.os_kind() {
OsKind::Linux(_) => Ok(()),
#[cfg(feature = "windows")]
OsKind::Windows(_) => Ok(()),
incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
"Host is {:?} but user management is only supported on Linux or Windows (with windows feature)",
incompatible_os_kind
))),
}
}
}
impl<Handler: HostHandler> AssessCompliance<Handler> for UserBlockExpectedState {
async fn assess_compliance(
&self,
host_handler: &mut Handler,
host_properties: &Option<HostProperties>,
privilege: &Privilege,
_optional_secret_provider: &Option<SecretProvidersPool>,
) -> Result<AttributeComplianceAssessment, RegentError> {
if let Some(props) = host_properties {
self.check_host_compatibility(props)?;
}
let os_kind = host_properties
.as_ref()
.map(|props| props.os_kind())
.unwrap_or(&OsKind::Linux(LinuxSpecifics {
linux_flavor: LinuxFlavor::Debian,
init_system: InitSystem::Systemd,
}));
let user_exists = match os_kind {
#[cfg(feature = "windows")]
OsKind::Windows(_) => match windows_user_exists(host_handler, &self.name).await {
Ok(exists) => exists,
Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
},
OsKind::Linux(_) => match user_exists(host_handler, &self.name).await {
Ok(exists) => exists,
Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
},
OsKind::MacOs(_) | OsKind::FreeBsd(_) | OsKind::Unknown => {
return Err(RegentError::FailedDryRunEvaluation(format!(
"User management is not supported on {:?}",
os_kind
)));
}
};
match &self.state {
UserExpectedState::Absent { remove_home } => {
if !user_exists {
return Ok(AttributeComplianceAssessment::Compliant);
}
return Ok(AttributeComplianceAssessment::NonCompliant(
RemediationsList::from(vec![Remediation::User(UserApiCall::from(
UserModuleInternalApiCall::Delete {
username: self.name.clone(),
remove_home: remove_home.unwrap_or(false),
},
privilege.clone(),
))])
.unwrap(),
));
}
UserExpectedState::Present { details } => {
if !user_exists {
return Ok(AttributeComplianceAssessment::NonCompliant(
RemediationsList::from(vec![Remediation::User(UserApiCall::from(
UserModuleInternalApiCall::Add {
username: self.name.clone(),
uid: details.uid,
group: details.group.clone(),
groups: details.groups.clone(),
shell: details.shell.clone(),
home: details.home.clone(),
comment: details.comment.clone(),
password: details.password.clone(),
system: details.system.unwrap_or(false),
create_home: details.create_home.unwrap_or(true),
},
privilege.clone(),
))])
.unwrap(),
));
}
match os_kind {
#[cfg(feature = "windows")]
OsKind::Windows(_) => {
Ok(AttributeComplianceAssessment::Compliant)
}
OsKind::Linux(_) => {
let mut mod_uid: Option<u32> = None;
let mut mod_group: Option<String> = None;
let mut mod_groups: Option<Vec<String>> = None;
let mut mod_shell: Option<String> = None;
let mut mod_home: Option<String> = None;
let mut mod_comment: Option<String> = None;
let append = details.append.unwrap_or(true);
let passwd_entry = match get_passwd_entry(host_handler, &self.name).await {
Ok(entry) => entry,
Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
};
if let Some(expected_uid) = details.uid {
if passwd_entry.uid != expected_uid {
mod_uid = Some(expected_uid);
}
}
if let Some(expected_shell) = &details.shell {
if passwd_entry.shell != *expected_shell {
mod_shell = Some(expected_shell.clone());
}
}
if let Some(expected_home) = &details.home {
if passwd_entry.home != *expected_home {
mod_home = Some(expected_home.clone());
}
}
if let Some(expected_comment) = &details.comment {
if passwd_entry.comment != *expected_comment {
mod_comment = Some(expected_comment.clone());
}
}
if let Some(expected_group) = &details.group {
let current_group =
match get_primary_group(host_handler, &self.name).await {
Ok(g) => g,
Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
};
if ¤t_group != expected_group {
mod_group = Some(expected_group.clone());
}
}
if let Some(expected_groups) = &details.groups {
let current_supp_groups =
match get_supplementary_groups(host_handler, &self.name).await {
Ok(g) => g,
Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
};
let groups_compliant = if append {
expected_groups
.iter()
.all(|g| current_supp_groups.contains(g))
} else {
let mut expected_sorted = expected_groups.clone();
let mut current_sorted = current_supp_groups.clone();
expected_sorted.sort();
current_sorted.sort();
expected_sorted == current_sorted
};
if !groups_compliant {
if append {
let missing_groups: Vec<String> = expected_groups
.iter()
.filter(|g| !current_supp_groups.contains(g))
.cloned()
.collect();
if !missing_groups.is_empty() {
mod_groups = Some(missing_groups);
}
} else {
mod_groups = Some(expected_groups.clone());
}
}
}
let needs_non_password_modification = mod_uid.is_some()
|| mod_group.is_some()
|| mod_groups.is_some()
|| mod_shell.is_some()
|| mod_home.is_some()
|| mod_comment.is_some();
let password_needs_modification = details.password.is_some();
if needs_non_password_modification || password_needs_modification {
let mod_password = if password_needs_modification {
details.password.clone()
} else {
None
};
return Ok(AttributeComplianceAssessment::NonCompliant(
RemediationsList::from(vec![Remediation::User(UserApiCall::from(
UserModuleInternalApiCall::Modify {
username: self.name.clone(),
uid: mod_uid,
group: mod_group,
groups: mod_groups,
append,
shell: mod_shell,
home: mod_home,
comment: mod_comment,
password: mod_password,
},
privilege.clone(),
))])
.unwrap(),
));
}
Ok(AttributeComplianceAssessment::Compliant)
}
OsKind::MacOs(_) | OsKind::FreeBsd(_) | OsKind::Unknown => {
Err(RegentError::FailedDryRunEvaluation(format!(
"Detailed user management is not supported on {:?}",
os_kind
)))
}
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum UserModuleInternalApiCall {
Add {
username: String,
uid: Option<u32>,
group: Option<String>,
groups: Option<Vec<String>>,
shell: Option<String>,
home: Option<String>,
comment: Option<String>,
password: Option<String>,
system: bool,
create_home: bool,
},
Modify {
username: String,
uid: Option<u32>,
group: Option<String>,
groups: Option<Vec<String>>,
append: bool,
shell: Option<String>,
home: Option<String>,
comment: Option<String>,
password: Option<String>,
},
Delete {
username: String,
remove_home: bool,
},
}
impl std::fmt::Display for UserModuleInternalApiCall {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UserModuleInternalApiCall::Add { username, .. } => {
write!(f, "add user {}", username)
}
UserModuleInternalApiCall::Modify { username, .. } => {
write!(f, "modify user {}", username)
}
UserModuleInternalApiCall::Delete { username, .. } => {
write!(f, "delete user {}", username)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UserApiCall {
pub api_call: UserModuleInternalApiCall,
privilege: Privilege,
}
impl UserApiCall {
pub fn display(&self) -> String {
match &self.api_call {
UserModuleInternalApiCall::Add { username, .. } => format!("Add user {}", username),
UserModuleInternalApiCall::Modify { username, .. } => {
format!("Modify user {}", username)
}
UserModuleInternalApiCall::Delete { username, .. } => {
format!("Delete user {}", username)
}
}
}
fn from(api_call: UserModuleInternalApiCall, privilege: Privilege) -> UserApiCall {
UserApiCall {
api_call,
privilege,
}
}
}
impl Check for UserApiCall {
fn check(&self) -> Result<(), RegentError> {
Ok(())
}
fn check_host_compatibility(
&self,
host_properties: &HostProperties,
) -> Result<(), RegentError> {
match host_properties.os_kind() {
OsKind::Linux(_) => Ok(()),
#[cfg(feature = "windows")]
OsKind::Windows(_) => Ok(()),
incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
"Host is {:?} but user management is only supported on Linux or Windows (with windows feature)",
incompatible_os_kind
))),
}
}
}
impl<Handler: HostHandler> ReachCompliance<Handler> for UserApiCall {
async fn call(
&self,
host_handler: &mut Handler,
host_properties: &Option<HostProperties>,
_optional_secret_provider: &Option<SecretProvidersPool>,
) -> Result<InternalApiCallOutcome, RegentError> {
if let Some(props) = host_properties {
self.check_host_compatibility(props)?;
}
let os_kind = host_properties
.as_ref()
.map(|props| props.os_kind())
.unwrap_or(&OsKind::Linux(LinuxSpecifics {
linux_flavor: LinuxFlavor::Debian,
init_system: InitSystem::Systemd,
}));
match os_kind {
#[cfg(feature = "windows")]
OsKind::Windows(_) => {
let cmd_result = match &self.api_call {
UserModuleInternalApiCall::Add {
username,
password,
comment,
..
} => {
let mut cmd = format!("net user {} /add", username);
if let Some(pass) = password {
cmd.push_str(&format!(" {}", pass));
}
if let Some(comm) = comment {
cmd.push_str(&format!(" /comment:\"{}\"", comm));
}
host_handler.run_windows_command(&cmd).await
}
UserModuleInternalApiCall::Modify {
username,
comment,
password,
..
} => {
let mut cmd = format!("net user {}", username);
if let Some(_pass) = password {
cmd.push_str(" * /password:req"); }
if let Some(comm) = comment {
cmd.push_str(&format!(" /comment:\"{}\"", comm));
}
host_handler.run_windows_command(&cmd).await
}
UserModuleInternalApiCall::Delete { username, .. } => {
let cmd = format!("net user {} /delete", username);
host_handler.run_windows_command(&cmd).await
}
};
match cmd_result {
Ok(result) => {
if result.return_code == 0 {
Ok(InternalApiCallOutcome::Success(None))
} else {
Ok(InternalApiCallOutcome::Failure(format!(
"RC: {}, STDOUT: {}, STDERR: {}",
result.return_code, result.stdout, result.stderr
)))
}
}
Err(e) => Ok(InternalApiCallOutcome::Failure(format!(
"Command execution failed: {:?}",
e
))),
}
}
OsKind::Linux(_) => {
let (cmd, privilege) = match &self.api_call {
UserModuleInternalApiCall::Add {
username,
uid,
group,
groups,
shell,
home,
comment,
password,
system,
create_home,
} => {
let mut args: Vec<String> = Vec::new();
if let Some(u) = uid {
args.push(format!("-u {}", u));
}
if let Some(g) = group {
args.push(format!("-g {}", g));
}
if let Some(gs) = groups {
if !gs.is_empty() {
args.push(format!("-G {}", gs.join(",")));
}
}
if let Some(s) = shell {
args.push(format!("-s {}", s));
}
if let Some(h) = home {
args.push(format!("-d {}", h));
}
if let Some(c) = comment {
args.push(format!("-c '{}'", c));
}
if let Some(p) = password {
args.push(format!("-p '{}'", p));
}
if *system {
args.push("-r".to_string());
}
if *create_home {
args.push("-m".to_string());
} else {
args.push("-M".to_string());
}
(
format!("useradd {} {}", args.join(" "), username),
&self.privilege,
)
}
UserModuleInternalApiCall::Modify {
username,
uid,
group,
groups,
append,
shell,
home,
comment,
password,
} => {
let mut args: Vec<String> = Vec::new();
if let Some(u) = uid {
args.push(format!("-u {}", u));
}
if let Some(g) = group {
args.push(format!("-g {}", g));
}
if let Some(gs) = groups {
args.push(format!("-G \"{}\"", gs.join(",")));
if *append && !gs.is_empty() {
args.push("-a".to_string());
}
}
if let Some(s) = shell {
args.push(format!("-s {}", s));
}
if let Some(h) = home {
args.push(format!("-d {}", h));
}
if let Some(c) = comment {
args.push(format!("-c '{}'", c));
}
if let Some(p) = password {
args.push(format!("-p '{}'", p));
}
(
format!("usermod {} {}", args.join(" "), username),
&self.privilege,
)
}
UserModuleInternalApiCall::Delete {
username,
remove_home,
} => {
let flags = if *remove_home { "-r " } else { "" };
(format!("userdel {}{}", flags, username), &self.privilege)
}
};
let cmd_result = host_handler.run_command(cmd.as_str(), privilege).await;
match cmd_result {
Ok(result) => {
if result.return_code == 0 {
Ok(InternalApiCallOutcome::Success(None))
} else {
Ok(InternalApiCallOutcome::Failure(format!(
"RC: {}, STDOUT: {}, STDERR: {}",
result.return_code, result.stdout, result.stderr
)))
}
}
Err(e) => Ok(InternalApiCallOutcome::Failure(format!(
"Command execution failed: {:?}",
e
))),
}
}
OsKind::MacOs(_) | OsKind::FreeBsd(_) | OsKind::Unknown => {
Err(RegentError::FailedDryRunEvaluation(format!(
"User management is not supported on {:?}",
os_kind
)))
}
}
}
}
struct PasswdEntry {
uid: u32,
comment: String,
home: String,
shell: String,
}
async fn user_exists<Handler: HostHandler>(
host_handler: &mut Handler,
username: &str,
) -> Result<bool, String> {
match host_handler
.run_command(&format!("id {}", username), &Privilege::None)
.await
{
Ok(result) => Ok(result.return_code == 0),
Err(e) => Err(format!("Unable to check if user exists: {:?}", e)),
}
}
#[cfg(feature = "windows")]
async fn windows_user_exists<Handler: HostHandler>(
host_handler: &mut Handler,
username: &str,
) -> Result<bool, String> {
match host_handler
.run_windows_command(&format!("net user {}", username))
.await
{
Ok(result) => {
if result.return_code == 0 {
Ok(true)
} else if result.stderr.contains("not found") || result.stdout.contains("not found") {
Ok(false)
} else {
Ok(false)
}
}
Err(e) => Err(format!(
"Unable to check if user exists on Windows: {:?}",
e
)),
}
}
async fn get_passwd_entry<Handler: HostHandler>(
host_handler: &mut Handler,
username: &str,
) -> Result<PasswdEntry, String> {
match host_handler
.run_command(&format!("getent passwd {}", username), &Privilege::None)
.await
{
Ok(result) => {
if result.return_code != 0 {
return Err(format!("getent passwd failed for user {}", username));
}
let fields: Vec<&str> = result.stdout.trim().splitn(7, ':').collect();
if fields.len() < 7 {
return Err(format!(
"Unexpected getent passwd output for {}: {}",
username, result.stdout
));
}
let uid = fields[2]
.parse::<u32>()
.map_err(|e| format!("Invalid UID '{}': {}", fields[2], e))?;
Ok(PasswdEntry {
uid,
comment: fields[4].to_string(),
home: fields[5].to_string(),
shell: fields[6].to_string(),
})
}
Err(e) => Err(format!(
"Unable to get passwd entry for {}: {:?}",
username, e
)),
}
}
async fn get_primary_group<Handler: HostHandler>(
host_handler: &mut Handler,
username: &str,
) -> Result<String, String> {
match host_handler
.run_command(&format!("id -gn {}", username), &Privilege::None)
.await
{
Ok(result) => {
if result.return_code != 0 {
return Err(format!("id -gn failed for user {}", username));
}
Ok(result.stdout.trim().to_string())
}
Err(e) => Err(format!(
"Unable to get primary group for {}: {:?}",
username, e
)),
}
}
async fn get_supplementary_groups<Handler: HostHandler>(
host_handler: &mut Handler,
username: &str,
) -> Result<Vec<String>, String> {
let primary_group = get_primary_group(host_handler, username).await?;
match host_handler
.run_command(&format!("id -Gn {}", username), &Privilege::None)
.await
{
Ok(result) => {
if result.return_code != 0 {
return Err(format!("id -Gn failed for user {}", username));
}
Ok(result
.stdout
.trim()
.split_whitespace()
.map(|s| s.to_string())
.filter(|g| g != &primary_group)
.collect())
}
Err(e) => Err(format!(
"Unable to get supplementary groups for {}: {:?}",
username, e
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsing_user_module_block_from_yaml_str() {
let raw_attributes = "---
- Name: alice
State: !Present
Details:
Shell: /bin/bash
Comment: Alice Smith
Groups:
- sudo
- docker
- Name: bob
State: !Absent
";
let _attributes: Vec<UserBlockExpectedState> =
yaml_serde::from_str(raw_attributes).unwrap();
}
#[test]
fn yaml_and_rusty_api_matching() {
let raw_yaml_attribute = "---
Name: alice
State: !Present
Details:
Shell: /bin/bash
Comment: Alice Smith
Groups:
- sudo
- docker
";
let yaml_defined: UserBlockExpectedState =
yaml_serde::from_str(raw_yaml_attribute).unwrap();
let rusty_defined = UserBlockExpectedState::present_with_details(
"alice",
UserDetails::default()
.with_shell("/bin/bash")
.with_comment("Alice Smith")
.with_groups(vec!["sudo".to_string(), "docker".to_string()])
.finish(),
);
assert_eq!(yaml_defined, rusty_defined);
}
}