Skip to main content

sysknife_daemon/actions/
sudoers.rs

1//! Scoped sudoers.d management.
2//!
3//! `GetSudoGrants` lists the SysKnife-managed drop-ins (read-only). `GrantSudoAccess`
4//! and `RevokeSudoAccess` create/remove a drop-in under `/etc/sudoers.d/` via the
5//! root-owned helper `/usr/lib/sysknife/sudoers-edit`, which validates the rule
6//! with `visudo -cf` on a temp file BEFORE installing it — so a malformed rule can
7//! never land in `/etc/sudoers.d/` and break sudo. This is the highest-privilege
8//! action family (it configures privilege escalation itself), hence Admin/High with
9//! exact-approval previews.
10
11use super::{command_mechanism, ActionSpec};
12use sysknife_types::RiskLevel;
13
14const HELPER: &str = "/usr/lib/sysknife/sudoers-edit";
15
16pub fn specs() -> Vec<ActionSpec> {
17    vec![
18        get_sudo_grants(),
19        grant_sudo_access("deploy-restart", "deploy", "/usr/bin/systemctl", None, true),
20        revoke_sudo_access("deploy-restart"),
21    ]
22}
23
24/// List SysKnife-managed sudoers drop-ins (`sudoers-edit --op list`). Read-only.
25///
26/// Run directly (no `sudo`): reading `/etc/sudoers.d/` needs root and the daemon
27/// already runs as root, matching the `GetLvmReport` pattern.
28pub fn get_sudo_grants() -> ActionSpec {
29    ActionSpec {
30        action_name: "GetSudoGrants",
31        mechanism: command_mechanism(HELPER, ["--op", "list"]),
32        risk_level: RiskLevel::Low,
33        reboot_required: false,
34        rollback_available: false,
35    }
36}
37
38/// Grant a scoped sudo rule via the visudo-validated helper.
39///
40/// `commands` is either `"ALL"` or a comma-separated list of absolute command
41/// paths. `runas` defaults to `root` when `None`.
42pub fn grant_sudo_access(
43    name: &str,
44    user: &str,
45    commands: &str,
46    runas: Option<&str>,
47    nopasswd: bool,
48) -> ActionSpec {
49    let mut args = vec![
50        HELPER.to_string(),
51        "--op".to_string(),
52        "grant".to_string(),
53        "--name".to_string(),
54        name.to_string(),
55        "--user".to_string(),
56        user.to_string(),
57        "--commands".to_string(),
58        commands.to_string(),
59    ];
60    if let Some(r) = runas {
61        args.push("--runas".to_string());
62        args.push(r.to_string());
63    }
64    if nopasswd {
65        args.push("--nopasswd".to_string());
66    }
67    ActionSpec {
68        action_name: "GrantSudoAccess",
69        mechanism: command_mechanism("sudo", args),
70        risk_level: RiskLevel::High,
71        reboot_required: false,
72        rollback_available: false,
73    }
74}
75
76/// Remove a SysKnife-managed sudoers drop-in (`sudoers-edit --op revoke`).
77pub fn revoke_sudo_access(name: &str) -> ActionSpec {
78    ActionSpec {
79        action_name: "RevokeSudoAccess",
80        mechanism: command_mechanism("sudo", [HELPER, "--op", "revoke", "--name", name]),
81        risk_level: RiskLevel::High,
82        reboot_required: false,
83        rollback_available: false,
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::actions::ActionMechanism;
91
92    fn args_of(spec: &ActionSpec) -> (&'static str, Vec<String>) {
93        match &spec.mechanism {
94            ActionMechanism::Command { program, args } => (program, args.clone()),
95            other => panic!("expected Command, got {other:?}"),
96        }
97    }
98
99    #[test]
100    fn list_is_read_only_bare_helper() {
101        let (program, args) = args_of(&get_sudo_grants());
102        assert_eq!(program, HELPER);
103        assert_eq!(args, vec!["--op", "list"]);
104        assert_eq!(get_sudo_grants().risk_level, RiskLevel::Low);
105    }
106
107    #[test]
108    fn grant_builds_flags_including_optional_runas_and_nopasswd() {
109        let (program, args) = args_of(&grant_sudo_access(
110            "ci",
111            "ci",
112            "/usr/bin/systemctl",
113            Some("root"),
114            true,
115        ));
116        assert_eq!(program, "sudo");
117        assert_eq!(
118            args,
119            vec![
120                HELPER,
121                "--op",
122                "grant",
123                "--name",
124                "ci",
125                "--user",
126                "ci",
127                "--commands",
128                "/usr/bin/systemctl",
129                "--runas",
130                "root",
131                "--nopasswd"
132            ]
133        );
134        // no runas, no nopasswd
135        let (_, minimal) = args_of(&grant_sudo_access("ci", "ci", "ALL", None, false));
136        assert!(!minimal.iter().any(|a| a == "--runas" || a == "--nopasswd"));
137        assert_eq!(
138            grant_sudo_access("a", "b", "ALL", None, false).risk_level,
139            RiskLevel::High
140        );
141    }
142
143    #[test]
144    fn revoke_shape() {
145        let (program, args) = args_of(&revoke_sudo_access("ci"));
146        assert_eq!(program, "sudo");
147        assert_eq!(args, vec![HELPER, "--op", "revoke", "--name", "ci"]);
148    }
149}