Skip to main content

regent_sdk/state/attribute/system/
group.rs

1//! Group management attribute
2//!
3//! This module provides the `GroupExpectedState` type for managing Unix system groups
4//! using the groupadd, groupmod, and groupdel commands (or lgroupadd, lgroupdel for local groups).
5//!
6//! **Compatible OS:** Linux (all distributions)
7//!
8//! # Examples
9//!
10//! ## Rust API
11//!
12//! ```no_run
13//! use regent_sdk::state::attribute::system::group::GroupExpectedState;
14//! use regent_sdk::{Attribute, ExpectedState, Privilege};
15//!
16//! // Create a group with a specific GID
17//! let developers = GroupExpectedState::present("developers", Some(1500), Some(vec!["daniel".to_string(), "chris".to_string()]), None, None);
18//!
19//! let group_cleaning = GroupExpectedState::absent("oldgroup", None);
20//!
21//! let expected_state = ExpectedState::new()
22//!     .with_attribute(Attribute::group(developers, Privilege::WithSudo, None))
23//!     .with_attribute(Attribute::group(group_cleaning, Privilege::WithSudo, None))
24//!     .build();
25//! ```
26//!
27//! ## YAML API
28//!
29//! ```yaml
30//! Attributes:
31//!   - Name: Developers group must be present
32//!     Privilege: !WithSudo
33//!     Detail: !Group
34//!       Name: developers
35//!       State: !Present
36//!         Gid: 1500
37//!         Members:
38//! ```
39
40use crate::error::RegentError;
41use crate::hosts::managed_host::InternalApiCallOutcome;
42use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
43use crate::hosts::properties::{HostProperties, OsKind};
44use crate::secrets::SecretProvidersPool;
45use crate::state::Check;
46use crate::state::attribute::HostHandler;
47use crate::state::attribute::Privilege;
48use crate::state::attribute::Remediation;
49use crate::state::attribute::RemediationsList;
50use crate::state::compliance::AttributeComplianceAssessment;
51use serde::{Deserialize, Serialize};
52use std::time::Duration;
53
54/// Desired state of a group
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "PascalCase")]
57pub enum GroupExpectedStatus {
58    Absent,
59    #[serde(rename_all = "PascalCase")]
60    Present {
61        /// Group ID to assign (optional)
62        gid: Option<u32>,
63        /// List of users that should be members of this group
64        members: Option<Vec<String>>,
65        /// Whether this is a system group (uses -r flag with groupadd)
66        system: Option<bool>,
67    },
68}
69
70/// Configuration for a system group
71///
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74#[serde(rename_all = "PascalCase")]
75pub struct GroupExpectedState {
76    name: String,
77    state: GroupExpectedStatus,
78    /// Whether to use local commands (lgroupadd/lgroupdel) instead of system commands
79    local: Option<bool>,
80}
81
82impl Timeout for GroupExpectedState {
83    fn default_timeout(&self) -> Duration {
84        Duration::from_secs(5)
85    }
86}
87
88impl GroupExpectedState {
89    pub fn absent(name: &str, local: Option<bool>) -> GroupExpectedState {
90        GroupExpectedState {
91            name: name.to_string(),
92            state: GroupExpectedStatus::Absent,
93            local,
94        }
95    }
96
97    pub fn present(
98        name: &str,
99        gid: Option<u32>,
100        members: Option<Vec<String>>,
101        system: Option<bool>,
102        local: Option<bool>,
103    ) -> GroupExpectedState {
104        GroupExpectedState {
105            name: name.to_string(),
106            state: GroupExpectedStatus::Present {
107                gid,
108                members,
109                system,
110            },
111            local,
112        }
113    }
114}
115
116impl Check for GroupExpectedState {
117    fn check(&self) -> Result<(), RegentError> {
118        Ok(())
119    }
120
121    fn check_host_compatibility(
122        &self,
123        host_properties: &HostProperties,
124    ) -> Result<(), RegentError> {
125        match host_properties.os_kind() {
126            OsKind::Linux(_) => Ok(()),
127            incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
128                "Host is {:?} but group management is only supported on Linux",
129                incompatible_os_kind
130            ))),
131        }
132    }
133}
134
135impl<Handler: HostHandler> AssessCompliance<Handler> for GroupExpectedState {
136    async fn assess_compliance(
137        &self,
138        host_handler: &mut Handler,
139        host_properties: &Option<HostProperties>,
140        privilege: &Privilege,
141        _optional_secret_provider: &Option<SecretProvidersPool>,
142    ) -> Result<AttributeComplianceAssessment, RegentError> {
143        if let Some(props) = host_properties {
144            self.check_host_compatibility(props)?;
145        }
146
147        let local = self.local.unwrap_or(false);
148
149        let opt_group_info = match get_group_info(host_handler, &self.name).await {
150            Ok(opt_group_info) => opt_group_info,
151            Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
152        };
153
154        let mut remediations: Vec<Remediation> = Vec::new();
155
156        match &self.state {
157            GroupExpectedStatus::Absent => {
158                if let Some(_group_info) = opt_group_info {
159                    remediations.push(Remediation::Group(GroupApiCall::from(
160                        GroupModuleInternalApiCall::Delete {
161                            groupname: self.name.clone(),
162                            local,
163                        },
164                        privilege.clone(),
165                    )));
166                };
167            }
168            GroupExpectedStatus::Present {
169                gid,
170                members,
171                system,
172            } => {
173                match opt_group_info {
174                    Some(current_group_info) => {
175                        if let Some(expected_gid) = gid
176                            && current_group_info.gid != *expected_gid
177                        {
178                            return Ok(AttributeComplianceAssessment::NonCompliant(
179                                RemediationsList::from(vec![Remediation::Group(
180                                    GroupApiCall::from(
181                                        GroupModuleInternalApiCall::ModifyGid {
182                                            groupname: self.name.clone(),
183                                            gid: *expected_gid,
184                                        },
185                                        privilege.clone(),
186                                    ),
187                                )])
188                                .unwrap(),
189                            ));
190                        }
191
192                        if let Some(expected_members) = members {
193                            // Sort both lists for comparison (order doesn't matter for group members)
194                            let mut expected_sorted = expected_members.clone();
195                            let mut current_sorted = current_group_info.members.clone();
196                            expected_sorted.sort();
197                            current_sorted.sort();
198
199                            if expected_sorted != current_sorted {
200                                remediations.push(Remediation::Group(GroupApiCall::from(
201                                    GroupModuleInternalApiCall::ModifyMembers {
202                                        groupname: self.name.clone(),
203                                        members: expected_members.clone(),
204                                    },
205                                    privilege.clone(),
206                                )));
207                            }
208                        }
209                    }
210                    None => {
211                        remediations.push(Remediation::Group(GroupApiCall::from(
212                            GroupModuleInternalApiCall::Add {
213                                groupname: self.name.clone(),
214                                gid: *gid,
215                                members: members.clone(),
216                                system: system.unwrap_or(false),
217                                local,
218                            },
219                            privilege.clone(),
220                        )));
221                    }
222                }
223            }
224        }
225
226        if remediations.is_empty() {
227            Ok(AttributeComplianceAssessment::Compliant)
228        } else {
229            Ok(AttributeComplianceAssessment::NonCompliant(
230                RemediationsList::from(remediations)?,
231            ))
232        }
233    }
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237#[serde(rename_all = "PascalCase")]
238pub enum GroupModuleInternalApiCall {
239    Add {
240        groupname: String,
241        gid: Option<u32>,
242        members: Option<Vec<String>>,
243        system: bool,
244        /// Uses lgroupadd instead of groupadd (shadow-utils local command)
245        local: bool,
246    },
247    ModifyGid {
248        groupname: String,
249        gid: u32,
250    },
251    ModifyMembers {
252        groupname: String,
253        members: Vec<String>,
254    },
255    Delete {
256        groupname: String,
257        local: bool,
258    },
259}
260
261impl std::fmt::Display for GroupModuleInternalApiCall {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        match self {
264            GroupModuleInternalApiCall::Add { groupname, .. } => {
265                write!(f, "add group {}", groupname)
266            }
267            GroupModuleInternalApiCall::ModifyGid { groupname, gid } => {
268                write!(f, "modify group {} gid to {}", groupname, gid)
269            }
270            GroupModuleInternalApiCall::ModifyMembers { groupname, members } => {
271                write!(f, "modify group {} members to {:?}", groupname, members)
272            }
273            GroupModuleInternalApiCall::Delete { groupname, .. } => {
274                write!(f, "delete group {}", groupname)
275            }
276        }
277    }
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281pub struct GroupApiCall {
282    pub api_call: GroupModuleInternalApiCall,
283    privilege: Privilege,
284}
285
286impl GroupApiCall {
287    pub fn display(&self) -> String {
288        match &self.api_call {
289            GroupModuleInternalApiCall::Add { groupname, .. } => {
290                format!("Add group {}", groupname)
291            }
292            GroupModuleInternalApiCall::ModifyGid { groupname, gid } => {
293                format!("Modify group {} GID to {}", groupname, gid)
294            }
295            GroupModuleInternalApiCall::ModifyMembers { groupname, members } => {
296                format!("Modify group {} members to {:?}", groupname, members)
297            }
298            GroupModuleInternalApiCall::Delete { groupname, .. } => {
299                format!("Delete group {}", groupname)
300            }
301        }
302    }
303
304    fn from(api_call: GroupModuleInternalApiCall, privilege: Privilege) -> GroupApiCall {
305        GroupApiCall {
306            api_call,
307            privilege,
308        }
309    }
310}
311
312impl Check for GroupApiCall {
313    fn check(&self) -> Result<(), RegentError> {
314        Ok(())
315    }
316
317    fn check_host_compatibility(
318        &self,
319        host_properties: &HostProperties,
320    ) -> Result<(), RegentError> {
321        match host_properties.os_kind() {
322            OsKind::Linux(_) => Ok(()),
323            incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
324                "Host is {:?} but group management is only supported on Linux",
325                incompatible_os_kind
326            ))),
327        }
328    }
329}
330
331impl<Handler: HostHandler> ReachCompliance<Handler> for GroupApiCall {
332    async fn call(
333        &self,
334        host_handler: &mut Handler,
335        host_properties: &Option<HostProperties>,
336        _optional_secret_provider: &Option<SecretProvidersPool>,
337    ) -> Result<InternalApiCallOutcome, RegentError> {
338        if let Some(props) = host_properties {
339            self.check_host_compatibility(props)?;
340        }
341
342        let (cmd, privilege) = match &self.api_call {
343            GroupModuleInternalApiCall::Add {
344                groupname,
345                gid,
346                members,
347                system,
348                local,
349            } => {
350                let base = if *local { "lgroupadd" } else { "groupadd" };
351                let mut args: Vec<String> = Vec::new();
352                if let Some(g) = gid {
353                    args.push(format!("-g {}", g));
354                }
355                if *system {
356                    args.push("-r".to_string());
357                }
358
359                let mut cmds = Vec::new();
360                cmds.push(format!("{} {} {}", base, args.join(" "), groupname));
361
362                if let Some(member_list) = members {
363                    for member in member_list {
364                        cmds.push(format!("usermod -aG {} {}", groupname, member));
365                    }
366                }
367
368                (cmds.join(" && "), &self.privilege)
369            }
370            GroupModuleInternalApiCall::ModifyGid { groupname, gid } => (
371                format!("groupmod -g {} {}", gid, groupname),
372                &self.privilege,
373            ),
374            GroupModuleInternalApiCall::ModifyMembers { groupname, members } => {
375                let current_members = match get_group_info(host_handler, groupname).await {
376                    Ok(opt_group_info) => {
377                        match opt_group_info {
378                            Some(group_info) => group_info.members,
379                            None => {
380                                return Ok(InternalApiCallOutcome::Failure("Failed to get current group members: group does not exist anymore".to_string()));
381                            }
382                        }
383                    }
384                    Err(e) => {
385                        return Ok(InternalApiCallOutcome::Failure(format!(
386                            "Failed to get current group members: {}",
387                            e
388                        )));
389                    }
390                };
391
392                use std::collections::HashSet;
393                let expected_set: HashSet<&str> = members.iter().map(|s| s.as_str()).collect();
394                let current_set: HashSet<&str> =
395                    current_members.iter().map(|s| s.as_str()).collect();
396
397                let users_to_add: Vec<&str> =
398                    expected_set.difference(&current_set).cloned().collect();
399                let users_to_remove: Vec<&str> =
400                    current_set.difference(&expected_set).cloned().collect();
401
402                let mut cmds = Vec::new();
403
404                for user in users_to_add {
405                    cmds.push(format!("usermod -aG {} {}", groupname, user));
406                }
407
408                for user in users_to_remove {
409                    cmds.push(format!("gpasswd --delete {} {}", user, groupname));
410                }
411
412                if cmds.is_empty() {
413                    return Ok(InternalApiCallOutcome::Success(None));
414                }
415
416                (cmds.join(" && "), &self.privilege)
417            }
418            GroupModuleInternalApiCall::Delete { groupname, local } => {
419                let base = if *local { "lgroupdel" } else { "groupdel" };
420                (format!("{} {}", base, groupname), &self.privilege)
421            }
422        };
423
424        let cmd_result = host_handler
425            .run_command(cmd.as_str(), privilege)
426            .await
427            .unwrap();
428
429        if cmd_result.return_code == 0 {
430            Ok(InternalApiCallOutcome::Success(None))
431        } else {
432            Ok(InternalApiCallOutcome::Failure(format!(
433                "RC: {}, STDOUT: {}, STDERR: {}",
434                cmd_result.return_code, cmd_result.stdout, cmd_result.stderr
435            )))
436        }
437    }
438}
439
440struct Group {
441    #[allow(dead_code)]
442    name: String,
443    gid: u32,
444    members: Vec<String>,
445}
446
447async fn get_group_info<Handler: HostHandler>(
448    host_handler: &mut Handler,
449    groupname: &str,
450) -> Result<Option<Group>, String> {
451    match host_handler
452        .run_command(&format!("getent group {}", groupname), &Privilege::None)
453        .await
454    {
455        Ok(result) => {
456            if result.return_code == 0 {
457                // Format: groupname:x:gid:members
458                let fields: Vec<&str> = result.stdout.trim().splitn(4, ':').collect();
459                if fields.len() < 4 {
460                    return Err(format!(
461                        "Unexpected getent group output for {}: {}",
462                        groupname, result.stdout
463                    ));
464                }
465                let gid = match fields[2].parse::<u32>() {
466                    Ok(gid) => gid,
467                    Err(e) => {
468                        return Err(format!("Invalid GID '{}': {}", fields[2], e));
469                    }
470                };
471                let members = fields[3]
472                    .split(',')
473                    .filter(|s| !s.trim().is_empty())
474                    .map(|s| s.trim().to_string())
475                    .collect();
476
477                Ok(Some(Group {
478                    name: groupname.to_string(),
479                    gid,
480                    members,
481                }))
482            } else {
483                Ok(None)
484            }
485        }
486        Err(e) => Err(format!("Unable to check if group exists: {:?}", e)),
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn parsing_group_module_block_from_yaml_str() {
496        let raw_attributes = "---
497- Name: developers
498  State: !Present
499    Gid: 1500
500    Members:
501        - daniel
502        - chris
503
504- Name: oldgroup
505  State: !Absent
506        ";
507
508        let attributes: Vec<GroupExpectedState> = yaml_serde::from_str(raw_attributes).unwrap();
509
510        println!("{:#?}", attributes);
511    }
512}