1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "PascalCase")]
57pub enum GroupExpectedStatus {
58 Absent,
59 #[serde(rename_all = "PascalCase")]
60 Present {
61 gid: Option<u32>,
63 members: Option<Vec<String>>,
65 system: Option<bool>,
67 },
68}
69
70#[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 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 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 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(¤t_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 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}