Skip to main content

isb_core/org/
nesting.rs

1//! The Docker exception (docs/concepts/security.md#the-docker-exception):
2//! an org setting, `allow_nesting`, that lets the org's workspace and
3//! nothing else run with `security.nesting`, so Docker works inside it.
4//!
5//! Two layers keep it to the workspace:
6//!
7//! - **The project.** An org's restricted project blocks nesting and
8//!   system-call interception (incus' defaults). With the setting on it
9//!   allows both (`restricted.containers.nesting=allow`,
10//!   `restricted.containers.interception=allow`), and records the setting
11//!   as `user.isb.allow-nesting`.
12//! - **isb itself.** Once the project allows them, any instance in it could
13//!   ask for the keys, so [`check_config`] refuses them in every org project
14//!   for every instance that is not a workspace built by the daemon
15//!   ([`crate::spec::SandboxSpec::workspace_nesting`], which no spec,
16//!   compose file or tool argument can set). Sandboxes, stack replicas,
17//!   apps and builds never get them, whoever asks, superadmins included.
18
19use super::*;
20
21/// The project key that records the setting.
22pub const KEY_ALLOW_NESTING: &str = "user.isb.allow-nesting";
23
24/// What the workspace gets when its org allows nesting: what unprivileged
25/// Docker needs in an incus container. `mknod` and `setxattr` interception
26/// let the daemon make device nodes and set the overlay filesystem's
27/// extended attributes from inside a user namespace.
28pub const WORKSPACE_KEYS: [(&str, &str); 3] = [
29    ("security.nesting", "true"),
30    ("security.syscalls.intercept.mknod", "true"),
31    ("security.syscalls.intercept.setxattr", "true"),
32];
33
34/// A config key only a nesting workspace may carry in an org.
35pub fn is_nesting_key(k: &str) -> bool {
36    k == "security.nesting" || k.starts_with("security.syscalls.intercept.")
37}
38
39/// Whether a project's config allows nesting for its workspace.
40pub fn allowed(cfg: &Value) -> bool {
41    cfg[KEY_ALLOW_NESTING].as_str() == Some("true")
42}
43
44/// The project keys for the setting.
45fn project_keys(on: bool) -> [(&'static str, &'static str); 3] {
46    let (r, flag) = if on { ("allow", "true") } else { ("block", "") };
47    [
48        ("restricted.containers.nesting", r),
49        ("restricted.containers.interception", r),
50        (KEY_ALLOW_NESTING, flag),
51    ]
52}
53
54/// Refuse nesting keys in an org project's instance config, unless the
55/// instance is a workspace the daemon builds. Projects outside isb's orgs
56/// (incus' own `default`) are the host's: there `isb up` may ask for
57/// nesting as it always could.
58pub fn check_config(
59    name: &str,
60    org: Option<&OrgId>,
61    config: &BTreeMap<String, String>,
62    workspace: bool,
63) -> Result<()> {
64    let Some(org) = org else {
65        return Ok(());
66    };
67    if workspace {
68        return Ok(());
69    }
70    match config.keys().find(|k| is_nesting_key(k)) {
71        Some(k) => Err(Error::invalid(format!(
72            "{name}: {k} is refused in org {org}: only the org's workspace may nest, and only when a superadmin allows it (docs/concepts/security.md#the-docker-exception)"
73        ))),
74        None => Ok(()),
75    }
76}
77
78/// Turn the setting on or off on the org's project. Turning it off fails
79/// (incus refuses) while an instance in the org still has nesting; the
80/// daemon takes it off the workspace first.
81pub fn set(base: &Client, org: &OrgId, on: bool) -> Result<()> {
82    let h = host(base);
83    let pp = format!("/1.0/projects/{}", encode_segment(&org.incus_project()));
84    let p = h
85        .get_opt(&pp)?
86        .ok_or_else(|| Error::NotFound(format!("org {org}")))?;
87    let mut cfg = p["config"].clone();
88    for (k, v) in project_keys(on) {
89        cfg[k] = json!(v);
90    }
91    h.mutate(
92        "PUT",
93        &pp,
94        Some(&json!({"description": p["description"], "config": cfg})),
95        &format!(
96            "{} nesting in org {org}",
97            if on { "allow" } else { "block" }
98        ),
99        h.get_timeouts().other,
100    )?;
101    Ok(())
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn cfg(keys: &[&str]) -> BTreeMap<String, String> {
109        keys.iter()
110            .map(|k| (k.to_string(), "true".into()))
111            .collect()
112    }
113
114    #[test]
115    fn only_a_workspace_may_nest_in_an_org() {
116        let acme = OrgId::new("acme").unwrap();
117        for k in [
118            "security.nesting",
119            "security.syscalls.intercept.mknod",
120            "security.syscalls.intercept.setxattr",
121            "security.syscalls.intercept.mount",
122        ] {
123            let e = check_config("web-1", Some(&acme), &cfg(&[k]), false).unwrap_err();
124            assert!(e.to_string().contains("only the org's workspace"), "{e}");
125            assert!(check_config("workspace", Some(&acme), &cfg(&[k]), true).is_ok());
126        }
127        assert!(check_config("web-1", Some(&acme), &cfg(&["boot.autostart"]), false).is_ok());
128        // Outside isb's orgs the host decides.
129        assert!(check_config("dev", None, &cfg(&["security.nesting"]), false).is_ok());
130    }
131
132    #[test]
133    fn a_spec_asking_for_nesting_in_an_org_is_refused_unless_it_is_the_workspace() {
134        let ids = "root:1000000:1000000000\n".to_string();
135        let host = crate::plan::HostFacts {
136            subids: crate::idmap::SubIds {
137                subuid: ids.clone(),
138                subgid: ids,
139                caller_owned: false,
140            },
141            pools: vec!["default".into()],
142            path_map: None,
143            initial_copy: false,
144            initial_owner: false,
145            incus_version: Some("7.5.1".into()),
146            invoking_ids: (1000, 1000),
147            shared_root: None,
148            org: Some(OrgId::new("acme").unwrap()),
149            registry: None,
150            project: "isb-acme".into(),
151        };
152        let resolve = |s: &crate::spec::SandboxSpec| {
153            crate::plan::resolve(s, &Default::default(), &host, Path::new("/"))
154        };
155        // A sandbox, a replica, an app: whatever asks, in an org.
156        let mut spec = crate::spec::SandboxSpec::new("web-1", "dev-base")
157            .raw_config("security.nesting", "true");
158        let e = resolve(&spec).unwrap_err();
159        assert!(e.to_string().contains("refused in org acme"), "{e}");
160        let intercept = crate::spec::SandboxSpec::new("web-2", "dev-base")
161            .raw_config("security.syscalls.intercept.mknod", "true");
162        assert!(resolve(&intercept).is_err());
163        // The daemon's workspace, and only it, may.
164        spec.workspace_nesting = true;
165        let d = resolve(&spec).unwrap();
166        assert_eq!(d.config["security.nesting"], "true");
167        // No spec can claim to be that workspace.
168        let y = "image: dev-base\nworkspace_nesting: true\n";
169        assert!(serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(y).is_err());
170        let j = json!({"image": "dev-base", "workspace_nesting": true});
171        assert!(serde_json::from_value::<crate::spec::SandboxSpec>(j).is_err());
172    }
173
174    #[test]
175    fn the_setting_opens_and_closes_the_project() {
176        assert_eq!(
177            project_keys(true),
178            [
179                ("restricted.containers.nesting", "allow"),
180                ("restricted.containers.interception", "allow"),
181                (KEY_ALLOW_NESTING, "true"),
182            ]
183        );
184        assert_eq!(project_keys(false)[0].1, "block");
185        assert_eq!(project_keys(false)[1].1, "block");
186        assert!(allowed(&json!({KEY_ALLOW_NESTING: "true"})));
187        assert!(!allowed(&json!({KEY_ALLOW_NESTING: ""})));
188        assert!(!allowed(&json!({})));
189        assert!(WORKSPACE_KEYS.iter().all(|(k, _)| is_nesting_key(k)));
190    }
191}