Skip to main content

regent_sdk/state/attribute/system/
user.rs

1//! User account management attribute
2//!
3//! This module provides the `UserBlockExpectedState` type for managing user accounts
4//! on Unix-like systems and Windows.
5//!
6//! **Compatible OS:**
7//! - Linux (all distributions) - uses useradd/usermod/userdel
8//! - Windows (when `windows` feature is enabled) - uses net user commands
9//!
10//! # Examples
11//!
12//! ## Rust API
13//!
14//! ```no_run
15//! use regent_sdk::state::attribute::system::user::{UserBlockExpectedState, UserExpectedState};
16//! use regent_sdk::{Attribute, ExpectedState, Privilege};
17//!
18//! // Create a user with specific properties
19//! let alice = UserBlockExpectedState::builder("alice")
20//!     .with_state(UserExpectedState::Present)
21//!     .with_uid(1001)
22//!     .with_shell("/bin/bash")
23//!     .with_home("/home/alice")
24//!     .with_comment("Alice Smith")
25//!     .build()
26//!     .unwrap();
27//!
28//! let expected_state = ExpectedState::new()
29//!     .with_attribute(Attribute::user(alice, Privilege::WithSudo, None))
30//!     .build();
31//! ```
32//!
33//! ## YAML API
34//!
35//! ```yaml
36//! Attributes:
37//!   - Name: User alice must be present
38//!     Privilege: !WithSudo
39//!     Detail: !User
40//!       Name: alice
41//!       State: !Present
42//!         Uid: 1001
43//!         Shell: /bin/bash
44//!         Home: /home/alice
45//!         Comment: Alice Smith
46//! ```
47
48use crate::error::RegentError;
49use crate::hosts::managed_host::InternalApiCallOutcome;
50use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
51use crate::hosts::properties::{HostProperties, InitSystem, LinuxFlavor, LinuxSpecifics, OsKind};
52use crate::secrets::SecretProvidersPool;
53use crate::state::Check;
54use crate::state::attribute::HostHandler;
55use crate::state::attribute::Privilege;
56use crate::state::attribute::Remediation;
57use crate::state::attribute::RemediationsList;
58use crate::state::compliance::AttributeComplianceAssessment;
59use serde::{Deserialize, Serialize};
60use std::time::Duration;
61
62/// Desired state of a user account
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "PascalCase")]
65pub enum UserExpectedState {
66    /// User should not exist
67    Absent {
68        /// Whether to remove home directory when deleting user
69        remove_home: Option<bool>,
70    },
71    /// User should exist
72    #[serde(rename_all = "PascalCase")]
73    Present { details: UserDetails },
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78#[serde(rename_all = "PascalCase")]
79pub struct UserDetails {
80    /// User ID
81    uid: Option<u32>,
82    /// Primary group name
83    group: Option<String>,
84    /// Supplementary groups the user should belong to
85    groups: Option<Vec<String>>,
86    /// Whether to append to existing supplementary groups instead of replacing them
87    append: Option<bool>,
88    /// Login shell
89    shell: Option<String>,
90    /// Home directory path
91    home: Option<String>,
92    /// GECOS comment field
93    comment: Option<String>,
94    /// Hashed password
95    password: Option<String>,
96    /// Whether this is a system user (no login shell, no home directory by default)
97    system: Option<bool>,
98    /// Whether to create home directory when creating user
99    create_home: Option<bool>,
100}
101
102impl UserDetails {
103    pub fn default() -> Self {
104        Self {
105            uid: None,
106            group: None,
107            groups: None,
108            append: None,
109            shell: None,
110            home: None,
111            comment: None,
112            password: None,
113            system: None,
114            create_home: None,
115        }
116    }
117
118    pub fn with_uid(&mut self, uid: u32) -> &mut Self {
119        self.uid = Some(uid);
120        self
121    }
122
123    pub fn with_group(&mut self, group: &str) -> &mut Self {
124        self.group = Some(group.to_string());
125        self
126    }
127
128    pub fn with_groups(&mut self, groups: Vec<String>) -> &mut Self {
129        self.groups = Some(groups);
130        self
131    }
132
133    pub fn with_append(&mut self, append: bool) -> &mut Self {
134        self.append = Some(append);
135        self
136    }
137
138    pub fn with_shell(&mut self, shell: &str) -> &mut Self {
139        self.shell = Some(shell.to_string());
140        self
141    }
142
143    pub fn with_home(&mut self, home: &str) -> &mut Self {
144        self.home = Some(home.to_string());
145        self
146    }
147
148    pub fn with_comment(&mut self, comment: &str) -> &mut Self {
149        self.comment = Some(comment.to_string());
150        self
151    }
152
153    pub fn with_password(&mut self, password: &str) -> &mut Self {
154        self.password = Some(password.to_string());
155        self
156    }
157
158    pub fn with_system(&mut self, system: bool) -> &mut Self {
159        self.system = Some(system);
160        self
161    }
162
163    pub fn with_create_home(&mut self, create_home: bool) -> &mut Self {
164        self.create_home = Some(create_home);
165        self
166    }
167
168    pub fn finish(&mut self) -> Self {
169        self.clone()
170    }
171}
172
173/// Configuration for a user account
174///
175/// Use the builder pattern to create and manage user accounts with various properties.
176/// When state is Present, you can specify user properties like UID, shell, home directory, etc.
177/// When state is Absent, the user will be removed (and optionally their home directory).
178///
179/// The `append` field controls whether supplementary groups are appended to existing groups
180/// or replaced entirely.
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183#[serde(rename_all = "PascalCase")]
184pub struct UserBlockExpectedState {
185    /// Username
186    name: String,
187    /// Desired state of the user (defaults to Present if not specified)
188    state: UserExpectedState,
189}
190
191impl Timeout for UserBlockExpectedState {
192    fn default_timeout(&self) -> Duration {
193        Duration::from_secs(5)
194    }
195}
196
197impl UserBlockExpectedState {
198    pub fn absent(&mut self, username: &str, remove_home: Option<bool>) -> UserBlockExpectedState {
199        UserBlockExpectedState {
200            name: username.to_string(),
201            state: UserExpectedState::Absent { remove_home },
202        }
203    }
204
205    pub fn present_with_defaults(username: &str) -> UserBlockExpectedState {
206        UserBlockExpectedState {
207            name: username.to_string(),
208            state: UserExpectedState::Present {
209                details: UserDetails::default(),
210            },
211        }
212    }
213
214    pub fn present_with_details(username: &str, details: UserDetails) -> UserBlockExpectedState {
215        UserBlockExpectedState {
216            name: username.to_string(),
217            state: UserExpectedState::Present { details },
218        }
219    }
220}
221
222impl Check for UserBlockExpectedState {
223    fn check(&self) -> Result<(), RegentError> {
224        Ok(())
225    }
226
227    fn check_host_compatibility(
228        &self,
229        host_properties: &HostProperties,
230    ) -> Result<(), RegentError> {
231        match host_properties.os_kind() {
232            OsKind::Linux(_) => Ok(()),
233            #[cfg(feature = "windows")]
234            OsKind::Windows(_) => Ok(()),
235            incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
236                "Host is {:?} but user management is only supported on Linux or Windows (with windows feature)",
237                incompatible_os_kind
238            ))),
239        }
240    }
241}
242
243impl<Handler: HostHandler> AssessCompliance<Handler> for UserBlockExpectedState {
244    async fn assess_compliance(
245        &self,
246        host_handler: &mut Handler,
247        host_properties: &Option<HostProperties>,
248        privilege: &Privilege,
249        _optional_secret_provider: &Option<SecretProvidersPool>,
250    ) -> Result<AttributeComplianceAssessment, RegentError> {
251        // Early check: verify we're on a compatible host
252        if let Some(props) = host_properties {
253            self.check_host_compatibility(props)?;
254        }
255
256        // Determine the effective OS kind - assume Linux if HostProperties is None
257        let os_kind = host_properties
258            .as_ref()
259            .map(|props| props.os_kind())
260            .unwrap_or(&OsKind::Linux(LinuxSpecifics {
261                linux_flavor: LinuxFlavor::Debian,
262                init_system: InitSystem::Systemd,
263            }));
264
265        // Check if user exists using OS-specific method
266        let user_exists = match os_kind {
267            #[cfg(feature = "windows")]
268            OsKind::Windows(_) => match windows_user_exists(host_handler, &self.name).await {
269                Ok(exists) => exists,
270                Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
271            },
272            OsKind::Linux(_) => match user_exists(host_handler, &self.name).await {
273                Ok(exists) => exists,
274                Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
275            },
276            OsKind::MacOs(_) | OsKind::FreeBsd(_) | OsKind::Unknown => {
277                return Err(RegentError::FailedDryRunEvaluation(format!(
278                    "User management is not supported on {:?}",
279                    os_kind
280                )));
281            }
282        };
283
284        match &self.state {
285            UserExpectedState::Absent { remove_home } => {
286                if !user_exists {
287                    return Ok(AttributeComplianceAssessment::Compliant);
288                }
289                return Ok(AttributeComplianceAssessment::NonCompliant(
290                    RemediationsList::from(vec![Remediation::User(UserApiCall::from(
291                        UserModuleInternalApiCall::Delete {
292                            username: self.name.clone(),
293                            remove_home: remove_home.unwrap_or(false),
294                        },
295                        privilege.clone(),
296                    ))])
297                    .unwrap(),
298                ));
299            }
300            UserExpectedState::Present { details } => {
301                if !user_exists {
302                    return Ok(AttributeComplianceAssessment::NonCompliant(
303                        RemediationsList::from(vec![Remediation::User(UserApiCall::from(
304                            UserModuleInternalApiCall::Add {
305                                username: self.name.clone(),
306                                uid: details.uid,
307                                group: details.group.clone(),
308                                groups: details.groups.clone(),
309                                shell: details.shell.clone(),
310                                home: details.home.clone(),
311                                comment: details.comment.clone(),
312                                password: details.password.clone(),
313                                system: details.system.unwrap_or(false),
314                                create_home: details.create_home.unwrap_or(true),
315                            },
316                            privilege.clone(),
317                        ))])
318                        .unwrap(),
319                    ));
320                }
321
322                // For Windows, many user properties don't map directly, so we'll do a simpler check
323                // On Unix systems, we can check detailed properties
324                match os_kind {
325                    #[cfg(feature = "windows")]
326                    OsKind::Windows(_) => {
327                        // On Windows, we currently only check existence
328                        // Detailed property checking would require more complex parsing of net user output
329                        // For now, if the user exists and we want them to exist, we're compliant
330                        Ok(AttributeComplianceAssessment::Compliant)
331                    }
332                    OsKind::Linux(_) => {
333                        // User exists on Linux: determine which properties need updating
334                        let mut mod_uid: Option<u32> = None;
335                        let mut mod_group: Option<String> = None;
336                        let mut mod_groups: Option<Vec<String>> = None;
337                        let mut mod_shell: Option<String> = None;
338                        let mut mod_home: Option<String> = None;
339                        let mut mod_comment: Option<String> = None;
340                        let append = details.append.unwrap_or(true);
341
342                        let passwd_entry = match get_passwd_entry(host_handler, &self.name).await {
343                            Ok(entry) => entry,
344                            Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
345                        };
346
347                        if let Some(expected_uid) = details.uid {
348                            if passwd_entry.uid != expected_uid {
349                                mod_uid = Some(expected_uid);
350                            }
351                        }
352
353                        if let Some(expected_shell) = &details.shell {
354                            if passwd_entry.shell != *expected_shell {
355                                mod_shell = Some(expected_shell.clone());
356                            }
357                        }
358
359                        if let Some(expected_home) = &details.home {
360                            if passwd_entry.home != *expected_home {
361                                mod_home = Some(expected_home.clone());
362                            }
363                        }
364
365                        if let Some(expected_comment) = &details.comment {
366                            if passwd_entry.comment != *expected_comment {
367                                mod_comment = Some(expected_comment.clone());
368                            }
369                        }
370
371                        if let Some(expected_group) = &details.group {
372                            let current_group =
373                                match get_primary_group(host_handler, &self.name).await {
374                                    Ok(g) => g,
375                                    Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
376                                };
377                            if &current_group != expected_group {
378                                mod_group = Some(expected_group.clone());
379                            }
380                        }
381
382                        if let Some(expected_groups) = &details.groups {
383                            let current_supp_groups =
384                                match get_supplementary_groups(host_handler, &self.name).await {
385                                    Ok(g) => g,
386                                    Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
387                                };
388
389                            let groups_compliant = if append {
390                                // All expected groups must already be present
391                                expected_groups
392                                    .iter()
393                                    .all(|g| current_supp_groups.contains(g))
394                            } else {
395                                // Exact match required
396                                let mut expected_sorted = expected_groups.clone();
397                                let mut current_sorted = current_supp_groups.clone();
398                                expected_sorted.sort();
399                                current_sorted.sort();
400                                expected_sorted == current_sorted
401                            };
402
403                            if !groups_compliant {
404                                if append {
405                                    // For append mode, only add the missing groups
406                                    let missing_groups: Vec<String> = expected_groups
407                                        .iter()
408                                        .filter(|g| !current_supp_groups.contains(g))
409                                        .cloned()
410                                        .collect();
411                                    if !missing_groups.is_empty() {
412                                        mod_groups = Some(missing_groups);
413                                    }
414                                } else {
415                                    // For replace mode, set all expected groups
416                                    mod_groups = Some(expected_groups.clone());
417                                }
418                            }
419                        }
420
421                        // Check if any non-password properties need modification
422                        let needs_non_password_modification = mod_uid.is_some()
423                            || mod_group.is_some()
424                            || mod_groups.is_some()
425                            || mod_shell.is_some()
426                            || mod_home.is_some()
427                            || mod_comment.is_some();
428
429                        // Check if password needs modification (can't verify current password, so only if specified)
430                        let password_needs_modification = details.password.is_some();
431
432                        if needs_non_password_modification || password_needs_modification {
433                            // If both password and other properties need modification, include both
434                            // If only other properties need modification, don't include password
435                            // If only password needs modification, only include password
436                            let mod_password = if password_needs_modification {
437                                details.password.clone()
438                            } else {
439                                None
440                            };
441
442                            return Ok(AttributeComplianceAssessment::NonCompliant(
443                                RemediationsList::from(vec![Remediation::User(UserApiCall::from(
444                                    UserModuleInternalApiCall::Modify {
445                                        username: self.name.clone(),
446                                        uid: mod_uid,
447                                        group: mod_group,
448                                        groups: mod_groups,
449                                        append,
450                                        shell: mod_shell,
451                                        home: mod_home,
452                                        comment: mod_comment,
453                                        password: mod_password,
454                                    },
455                                    privilege.clone(),
456                                ))])
457                                .unwrap(),
458                            ));
459                        }
460
461                        Ok(AttributeComplianceAssessment::Compliant)
462                    }
463                    OsKind::MacOs(_) | OsKind::FreeBsd(_) | OsKind::Unknown => {
464                        Err(RegentError::FailedDryRunEvaluation(format!(
465                            "Detailed user management is not supported on {:?}",
466                            os_kind
467                        )))
468                    }
469                }
470            }
471        }
472    }
473}
474
475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476#[serde(rename_all = "PascalCase")]
477pub enum UserModuleInternalApiCall {
478    Add {
479        username: String,
480        uid: Option<u32>,
481        group: Option<String>,
482        groups: Option<Vec<String>>,
483        shell: Option<String>,
484        home: Option<String>,
485        comment: Option<String>,
486        password: Option<String>,
487        system: bool,
488        create_home: bool,
489    },
490    Modify {
491        username: String,
492        uid: Option<u32>,
493        group: Option<String>,
494        groups: Option<Vec<String>>,
495        append: bool,
496        shell: Option<String>,
497        home: Option<String>,
498        comment: Option<String>,
499        password: Option<String>,
500    },
501    Delete {
502        username: String,
503        remove_home: bool,
504    },
505}
506
507impl std::fmt::Display for UserModuleInternalApiCall {
508    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509        match self {
510            UserModuleInternalApiCall::Add { username, .. } => {
511                write!(f, "add user {}", username)
512            }
513            UserModuleInternalApiCall::Modify { username, .. } => {
514                write!(f, "modify user {}", username)
515            }
516            UserModuleInternalApiCall::Delete { username, .. } => {
517                write!(f, "delete user {}", username)
518            }
519        }
520    }
521}
522
523#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
524pub struct UserApiCall {
525    pub api_call: UserModuleInternalApiCall,
526    privilege: Privilege,
527}
528
529impl UserApiCall {
530    pub fn display(&self) -> String {
531        match &self.api_call {
532            UserModuleInternalApiCall::Add { username, .. } => format!("Add user {}", username),
533            UserModuleInternalApiCall::Modify { username, .. } => {
534                format!("Modify user {}", username)
535            }
536            UserModuleInternalApiCall::Delete { username, .. } => {
537                format!("Delete user {}", username)
538            }
539        }
540    }
541
542    fn from(api_call: UserModuleInternalApiCall, privilege: Privilege) -> UserApiCall {
543        UserApiCall {
544            api_call,
545            privilege,
546        }
547    }
548}
549
550impl Check for UserApiCall {
551    fn check(&self) -> Result<(), RegentError> {
552        Ok(())
553    }
554
555    fn check_host_compatibility(
556        &self,
557        host_properties: &HostProperties,
558    ) -> Result<(), RegentError> {
559        match host_properties.os_kind() {
560            OsKind::Linux(_) => Ok(()),
561            #[cfg(feature = "windows")]
562            OsKind::Windows(_) => Ok(()),
563            incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
564                "Host is {:?} but user management is only supported on Linux or Windows (with windows feature)",
565                incompatible_os_kind
566            ))),
567        }
568    }
569}
570
571impl<Handler: HostHandler> ReachCompliance<Handler> for UserApiCall {
572    async fn call(
573        &self,
574        host_handler: &mut Handler,
575        host_properties: &Option<HostProperties>,
576        _optional_secret_provider: &Option<SecretProvidersPool>,
577    ) -> Result<InternalApiCallOutcome, RegentError> {
578        // Early check: verify we're on a compatible host
579        if let Some(props) = host_properties {
580            self.check_host_compatibility(props)?;
581        }
582
583        // Determine the effective OS kind - assume Linux if HostProperties is None
584        let os_kind = host_properties
585            .as_ref()
586            .map(|props| props.os_kind())
587            .unwrap_or(&OsKind::Linux(LinuxSpecifics {
588                linux_flavor: LinuxFlavor::Debian,
589                init_system: InitSystem::Systemd,
590            }));
591
592        // Match on OS kind to execute the appropriate user command
593        match os_kind {
594            #[cfg(feature = "windows")]
595            OsKind::Windows(_) => {
596                let cmd_result = match &self.api_call {
597                    UserModuleInternalApiCall::Add {
598                        username,
599                        password,
600                        comment,
601                        ..
602                    } => {
603                        // Windows net user command for adding users
604                        // net user username password /add /comment:"comment"
605                        let mut cmd = format!("net user {} /add", username);
606
607                        if let Some(pass) = password {
608                            // Note: This sets a plaintext password - in real usage, this should be handled securely
609                            cmd.push_str(&format!(" {}", pass));
610                        }
611
612                        if let Some(comm) = comment {
613                            cmd.push_str(&format!(" /comment:\"{}\"", comm));
614                        }
615
616                        host_handler.run_windows_command(&cmd).await
617                    }
618                    UserModuleInternalApiCall::Modify {
619                        username,
620                        comment,
621                        password,
622                        ..
623                    } => {
624                        // Windows net user command for modifying users
625                        let mut cmd = format!("net user {}", username);
626
627                        if let Some(_pass) = password {
628                            cmd.push_str(" * /password:req"); // This prompts for password change
629                        }
630
631                        if let Some(comm) = comment {
632                            cmd.push_str(&format!(" /comment:\"{}\"", comm));
633                        }
634
635                        host_handler.run_windows_command(&cmd).await
636                    }
637                    UserModuleInternalApiCall::Delete { username, .. } => {
638                        // Windows net user command for deleting users
639                        let cmd = format!("net user {} /delete", username);
640                        host_handler.run_windows_command(&cmd).await
641                    }
642                };
643
644                match cmd_result {
645                    Ok(result) => {
646                        if result.return_code == 0 {
647                            Ok(InternalApiCallOutcome::Success(None))
648                        } else {
649                            Ok(InternalApiCallOutcome::Failure(format!(
650                                "RC: {}, STDOUT: {}, STDERR: {}",
651                                result.return_code, result.stdout, result.stderr
652                            )))
653                        }
654                    }
655                    Err(e) => Ok(InternalApiCallOutcome::Failure(format!(
656                        "Command execution failed: {:?}",
657                        e
658                    ))),
659                }
660            }
661            OsKind::Linux(_) => {
662                let (cmd, privilege) = match &self.api_call {
663                    UserModuleInternalApiCall::Add {
664                        username,
665                        uid,
666                        group,
667                        groups,
668                        shell,
669                        home,
670                        comment,
671                        password,
672                        system,
673                        create_home,
674                    } => {
675                        let mut args: Vec<String> = Vec::new();
676                        if let Some(u) = uid {
677                            args.push(format!("-u {}", u));
678                        }
679                        if let Some(g) = group {
680                            args.push(format!("-g {}", g));
681                        }
682                        if let Some(gs) = groups {
683                            if !gs.is_empty() {
684                                args.push(format!("-G {}", gs.join(",")));
685                            }
686                        }
687                        if let Some(s) = shell {
688                            args.push(format!("-s {}", s));
689                        }
690                        if let Some(h) = home {
691                            args.push(format!("-d {}", h));
692                        }
693                        if let Some(c) = comment {
694                            args.push(format!("-c '{}'", c));
695                        }
696                        if let Some(p) = password {
697                            args.push(format!("-p '{}'", p));
698                        }
699                        if *system {
700                            args.push("-r".to_string());
701                        }
702                        if *create_home {
703                            args.push("-m".to_string());
704                        } else {
705                            args.push("-M".to_string());
706                        }
707                        (
708                            format!("useradd {} {}", args.join(" "), username),
709                            &self.privilege,
710                        )
711                    }
712                    UserModuleInternalApiCall::Modify {
713                        username,
714                        uid,
715                        group,
716                        groups,
717                        append,
718                        shell,
719                        home,
720                        comment,
721                        password,
722                    } => {
723                        let mut args: Vec<String> = Vec::new();
724                        if let Some(u) = uid {
725                            args.push(format!("-u {}", u));
726                        }
727                        if let Some(g) = group {
728                            args.push(format!("-g {}", g));
729                        }
730                        if let Some(gs) = groups {
731                            // Quote the value to handle empty list (removes all supplementary groups)
732                            args.push(format!("-G \"{}\"", gs.join(",")));
733                            if *append && !gs.is_empty() {
734                                args.push("-a".to_string());
735                            }
736                        }
737                        if let Some(s) = shell {
738                            args.push(format!("-s {}", s));
739                        }
740                        if let Some(h) = home {
741                            args.push(format!("-d {}", h));
742                        }
743                        if let Some(c) = comment {
744                            args.push(format!("-c '{}'", c));
745                        }
746                        if let Some(p) = password {
747                            args.push(format!("-p '{}'", p));
748                        }
749                        (
750                            format!("usermod {} {}", args.join(" "), username),
751                            &self.privilege,
752                        )
753                    }
754                    UserModuleInternalApiCall::Delete {
755                        username,
756                        remove_home,
757                    } => {
758                        let flags = if *remove_home { "-r " } else { "" };
759                        (format!("userdel {}{}", flags, username), &self.privilege)
760                    }
761                };
762
763                let cmd_result = host_handler.run_command(cmd.as_str(), privilege).await;
764
765                match cmd_result {
766                    Ok(result) => {
767                        if result.return_code == 0 {
768                            Ok(InternalApiCallOutcome::Success(None))
769                        } else {
770                            Ok(InternalApiCallOutcome::Failure(format!(
771                                "RC: {}, STDOUT: {}, STDERR: {}",
772                                result.return_code, result.stdout, result.stderr
773                            )))
774                        }
775                    }
776                    Err(e) => Ok(InternalApiCallOutcome::Failure(format!(
777                        "Command execution failed: {:?}",
778                        e
779                    ))),
780                }
781            }
782            OsKind::MacOs(_) | OsKind::FreeBsd(_) | OsKind::Unknown => {
783                Err(RegentError::FailedDryRunEvaluation(format!(
784                    "User management is not supported on {:?}",
785                    os_kind
786                )))
787            }
788        }
789    }
790}
791
792struct PasswdEntry {
793    uid: u32,
794    comment: String,
795    home: String,
796    shell: String,
797}
798
799async fn user_exists<Handler: HostHandler>(
800    host_handler: &mut Handler,
801    username: &str,
802) -> Result<bool, String> {
803    match host_handler
804        .run_command(&format!("id {}", username), &Privilege::None)
805        .await
806    {
807        Ok(result) => Ok(result.return_code == 0),
808        Err(e) => Err(format!("Unable to check if user exists: {:?}", e)),
809    }
810}
811
812#[cfg(feature = "windows")]
813async fn windows_user_exists<Handler: HostHandler>(
814    host_handler: &mut Handler,
815    username: &str,
816) -> Result<bool, String> {
817    match host_handler
818        .run_windows_command(&format!("net user {}", username))
819        .await
820    {
821        Ok(result) => {
822            // net user returns 0 for success (user exists), non-zero if user doesn't exist
823            // But it also returns non-zero for other errors, so we need to check the output
824            if result.return_code == 0 {
825                Ok(true)
826            } else if result.stderr.contains("not found") || result.stdout.contains("not found") {
827                Ok(false)
828            } else {
829                // Could be an error, but we'll assume user doesn't exist
830                Ok(false)
831            }
832        }
833        Err(e) => Err(format!(
834            "Unable to check if user exists on Windows: {:?}",
835            e
836        )),
837    }
838}
839
840async fn get_passwd_entry<Handler: HostHandler>(
841    host_handler: &mut Handler,
842    username: &str,
843) -> Result<PasswdEntry, String> {
844    match host_handler
845        .run_command(&format!("getent passwd {}", username), &Privilege::None)
846        .await
847    {
848        Ok(result) => {
849            if result.return_code != 0 {
850                return Err(format!("getent passwd failed for user {}", username));
851            }
852            // Format: username:x:uid:gid:comment:home:shell
853            let fields: Vec<&str> = result.stdout.trim().splitn(7, ':').collect();
854            if fields.len() < 7 {
855                return Err(format!(
856                    "Unexpected getent passwd output for {}: {}",
857                    username, result.stdout
858                ));
859            }
860            let uid = fields[2]
861                .parse::<u32>()
862                .map_err(|e| format!("Invalid UID '{}': {}", fields[2], e))?;
863            Ok(PasswdEntry {
864                uid,
865                comment: fields[4].to_string(),
866                home: fields[5].to_string(),
867                shell: fields[6].to_string(),
868            })
869        }
870        Err(e) => Err(format!(
871            "Unable to get passwd entry for {}: {:?}",
872            username, e
873        )),
874    }
875}
876
877async fn get_primary_group<Handler: HostHandler>(
878    host_handler: &mut Handler,
879    username: &str,
880) -> Result<String, String> {
881    match host_handler
882        .run_command(&format!("id -gn {}", username), &Privilege::None)
883        .await
884    {
885        Ok(result) => {
886            if result.return_code != 0 {
887                return Err(format!("id -gn failed for user {}", username));
888            }
889            Ok(result.stdout.trim().to_string())
890        }
891        Err(e) => Err(format!(
892            "Unable to get primary group for {}: {:?}",
893            username, e
894        )),
895    }
896}
897
898async fn get_supplementary_groups<Handler: HostHandler>(
899    host_handler: &mut Handler,
900    username: &str,
901) -> Result<Vec<String>, String> {
902    let primary_group = get_primary_group(host_handler, username).await?;
903
904    match host_handler
905        .run_command(&format!("id -Gn {}", username), &Privilege::None)
906        .await
907    {
908        Ok(result) => {
909            if result.return_code != 0 {
910                return Err(format!("id -Gn failed for user {}", username));
911            }
912            // id -Gn returns all groups (primary + supplementary); subtract primary
913            Ok(result
914                .stdout
915                .trim()
916                .split_whitespace()
917                .map(|s| s.to_string())
918                .filter(|g| g != &primary_group)
919                .collect())
920        }
921        Err(e) => Err(format!(
922            "Unable to get supplementary groups for {}: {:?}",
923            username, e
924        )),
925    }
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    #[test]
933    fn parsing_user_module_block_from_yaml_str() {
934        let raw_attributes = "---
935- Name: alice
936  State: !Present
937    Details:
938      Shell: /bin/bash
939      Comment: Alice Smith
940      Groups:
941        - sudo
942        - docker
943
944- Name: bob
945  State: !Absent
946        ";
947
948        let _attributes: Vec<UserBlockExpectedState> =
949            yaml_serde::from_str(raw_attributes).unwrap();
950    }
951
952    #[test]
953    fn yaml_and_rusty_api_matching() {
954        let raw_yaml_attribute = "---
955Name: alice
956State: !Present
957  Details:
958    Shell: /bin/bash
959    Comment: Alice Smith
960    Groups:
961      - sudo
962      - docker
963";
964
965        let yaml_defined: UserBlockExpectedState =
966            yaml_serde::from_str(raw_yaml_attribute).unwrap();
967
968        let rusty_defined = UserBlockExpectedState::present_with_details(
969            "alice",
970            UserDetails::default()
971                .with_shell("/bin/bash")
972                .with_comment("Alice Smith")
973                .with_groups(vec!["sudo".to_string(), "docker".to_string()])
974                .finish(),
975        );
976
977        assert_eq!(yaml_defined, rusty_defined);
978    }
979}