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        // `nopasswd: false` on purpose. This example is what the catalogue
20        // shows and what `docs/action-reference.md` publishes, and it used to
21        // read `--commands /usr/bin/systemctl --nopasswd`, which is a standing
22        // passwordless root credential: `systemctl edit` opens an editor,
23        // `systemctl link` takes an arbitrary unit path. The daemon refuses
24        // that combination now, so the published example was also an example
25        // that no longer runs. Keeping the realistic command and dropping the
26        // passwordless half is the accurate version of the same story.
27        grant_sudo_access(
28            "deploy-restart",
29            "deploy",
30            "/usr/bin/systemctl",
31            None,
32            false,
33        ),
34        revoke_sudo_access("deploy-restart"),
35    ]
36}
37
38/// List SysKnife-managed sudoers drop-ins (`sudoers-edit --op list`). Read-only.
39///
40/// Run directly (no `sudo`): reading `/etc/sudoers.d/` needs root and the daemon
41/// already runs as root, matching the `GetLvmReport` pattern.
42pub fn get_sudo_grants() -> ActionSpec {
43    ActionSpec {
44        action_name: "GetSudoGrants",
45        mechanism: command_mechanism(HELPER, ["--op", "list"]),
46        risk_level: RiskLevel::Low,
47        reboot_required: false,
48        rollback_available: false,
49    }
50}
51
52/// Grant a scoped sudo rule via the visudo-validated helper.
53///
54/// `commands` is either `"ALL"` or a comma-separated list of absolute command
55/// paths. `runas` defaults to `root` when `None`.
56pub fn grant_sudo_access(
57    name: &str,
58    user: &str,
59    commands: &str,
60    runas: Option<&str>,
61    nopasswd: bool,
62) -> ActionSpec {
63    let mut args = vec![
64        HELPER.to_string(),
65        "--op".to_string(),
66        "grant".to_string(),
67        "--name".to_string(),
68        name.to_string(),
69        "--user".to_string(),
70        user.to_string(),
71        "--commands".to_string(),
72        commands.to_string(),
73    ];
74    if let Some(r) = runas {
75        args.push("--runas".to_string());
76        args.push(r.to_string());
77    }
78    if nopasswd {
79        args.push("--nopasswd".to_string());
80    }
81    ActionSpec {
82        action_name: "GrantSudoAccess",
83        mechanism: command_mechanism("sudo", args),
84        risk_level: RiskLevel::High,
85        reboot_required: false,
86        rollback_available: false,
87    }
88}
89
90/// Remove a SysKnife-managed sudoers drop-in (`sudoers-edit --op revoke`).
91pub fn revoke_sudo_access(name: &str) -> ActionSpec {
92    ActionSpec {
93        action_name: "RevokeSudoAccess",
94        mechanism: command_mechanism("sudo", [HELPER, "--op", "revoke", "--name", name]),
95        risk_level: RiskLevel::High,
96        reboot_required: false,
97        rollback_available: false,
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::actions::ActionMechanism;
105
106    fn args_of(spec: &ActionSpec) -> (&'static str, Vec<String>) {
107        match &spec.mechanism {
108            ActionMechanism::Command { program, args } => (program, args.clone()),
109            other => panic!("expected Command, got {other:?}"),
110        }
111    }
112
113    #[test]
114    fn list_is_read_only_bare_helper() {
115        let (program, args) = args_of(&get_sudo_grants());
116        assert_eq!(program, HELPER);
117        assert_eq!(args, vec!["--op", "list"]);
118        assert_eq!(get_sudo_grants().risk_level, RiskLevel::Low);
119    }
120
121    #[test]
122    fn grant_builds_flags_including_optional_runas_and_nopasswd() {
123        let (program, args) = args_of(&grant_sudo_access(
124            "ci",
125            "ci",
126            "/usr/bin/systemctl",
127            Some("root"),
128            true,
129        ));
130        assert_eq!(program, "sudo");
131        assert_eq!(
132            args,
133            vec![
134                HELPER,
135                "--op",
136                "grant",
137                "--name",
138                "ci",
139                "--user",
140                "ci",
141                "--commands",
142                "/usr/bin/systemctl",
143                "--runas",
144                "root",
145                "--nopasswd"
146            ]
147        );
148        // no runas, no nopasswd
149        let (_, minimal) = args_of(&grant_sudo_access("ci", "ci", "ALL", None, false));
150        assert!(!minimal.iter().any(|a| a == "--runas" || a == "--nopasswd"));
151        assert_eq!(
152            grant_sudo_access("a", "b", "ALL", None, false).risk_level,
153            RiskLevel::High
154        );
155    }
156
157    #[test]
158    fn revoke_shape() {
159        let (program, args) = args_of(&revoke_sudo_access("ci"));
160        assert_eq!(program, "sudo");
161        assert_eq!(args, vec![HELPER, "--op", "revoke", "--name", "ci"]);
162    }
163}