Skip to main content

secure_exec_sidecar_core/
permissions.rs

1use crate::root_fs::SidecarCoreError;
2use secure_exec_bridge::FilesystemAccess;
3use secure_exec_kernel::permissions::{
4    permission_glob_matches, CommandAccessRequest, EnvAccessRequest, EnvironmentOperation,
5    FsAccessRequest, FsOperation, NetworkAccessRequest, NetworkOperation, PermissionDecision,
6    Permissions,
7};
8use secure_exec_vm_config as vm_config;
9use std::sync::Arc;
10
11pub fn deny_all_policy() -> vm_config::PermissionsPolicy {
12    vm_config::PermissionsPolicy {
13        fs: Some(vm_config::FsPermissionScope::Mode(
14            vm_config::PermissionMode::Deny,
15        )),
16        network: Some(vm_config::PatternPermissionScope::Mode(
17            vm_config::PermissionMode::Deny,
18        )),
19        child_process: Some(vm_config::PatternPermissionScope::Mode(
20            vm_config::PermissionMode::Deny,
21        )),
22        process: Some(vm_config::PatternPermissionScope::Mode(
23            vm_config::PermissionMode::Deny,
24        )),
25        env: Some(vm_config::PatternPermissionScope::Mode(
26            vm_config::PermissionMode::Deny,
27        )),
28        binding: Some(vm_config::PatternPermissionScope::Mode(
29            vm_config::PermissionMode::Deny,
30        )),
31    }
32}
33
34pub fn allow_all_policy() -> vm_config::PermissionsPolicy {
35    vm_config::PermissionsPolicy {
36        fs: Some(vm_config::FsPermissionScope::Mode(
37            vm_config::PermissionMode::Allow,
38        )),
39        network: Some(vm_config::PatternPermissionScope::Mode(
40            vm_config::PermissionMode::Allow,
41        )),
42        child_process: Some(vm_config::PatternPermissionScope::Mode(
43            vm_config::PermissionMode::Allow,
44        )),
45        process: Some(vm_config::PatternPermissionScope::Mode(
46            vm_config::PermissionMode::Allow,
47        )),
48        env: Some(vm_config::PatternPermissionScope::Mode(
49            vm_config::PermissionMode::Allow,
50        )),
51        binding: Some(vm_config::PatternPermissionScope::Mode(
52            vm_config::PermissionMode::Allow,
53        )),
54    }
55}
56
57pub fn evaluate_permissions_policy(
58    permissions: &vm_config::PermissionsPolicy,
59    domain: &str,
60    capability: &str,
61    resource: Option<&str>,
62) -> vm_config::PermissionMode {
63    match domain {
64        "fs" => evaluate_fs_permission_scope(
65            permissions.fs.as_ref(),
66            capability_operation(capability, domain),
67            resource,
68        ),
69        "network" => evaluate_pattern_permission_scope(
70            permissions.network.as_ref(),
71            capability_operation(capability, domain),
72            resource,
73        ),
74        "child_process" => evaluate_pattern_permission_scope(
75            permissions.child_process.as_ref(),
76            capability_operation(capability, domain),
77            resource,
78        ),
79        "process" => evaluate_pattern_permission_scope(
80            permissions.process.as_ref(),
81            capability_operation(capability, domain),
82            resource,
83        ),
84        "env" => evaluate_pattern_permission_scope(
85            permissions.env.as_ref(),
86            capability_operation(capability, domain),
87            resource,
88        ),
89        "binding" => evaluate_pattern_permission_scope(
90            permissions.binding.as_ref(),
91            capability_operation(capability, domain),
92            resource,
93        ),
94        _ => vm_config::PermissionMode::Deny,
95    }
96}
97
98fn evaluate_fs_permission_scope(
99    scope: Option<&vm_config::FsPermissionScope>,
100    operation: &str,
101    resource: Option<&str>,
102) -> vm_config::PermissionMode {
103    match scope {
104        Some(vm_config::FsPermissionScope::Mode(mode)) => *mode,
105        Some(vm_config::FsPermissionScope::Rules(rules)) => {
106            let mut mode = rules.default.unwrap_or(vm_config::PermissionMode::Deny);
107            for rule in &rules.rules {
108                if fs_rule_matches(rule, operation, resource) {
109                    mode = rule.mode;
110                }
111            }
112            mode
113        }
114        None => vm_config::PermissionMode::Deny,
115    }
116}
117
118fn evaluate_pattern_permission_scope(
119    scope: Option<&vm_config::PatternPermissionScope>,
120    operation: &str,
121    resource: Option<&str>,
122) -> vm_config::PermissionMode {
123    match scope {
124        Some(vm_config::PatternPermissionScope::Mode(mode)) => *mode,
125        Some(vm_config::PatternPermissionScope::Rules(rules)) => {
126            let mut mode = rules.default.unwrap_or(vm_config::PermissionMode::Deny);
127            for rule in &rules.rules {
128                if pattern_rule_matches(rule, operation, resource) {
129                    mode = rule.mode;
130                }
131            }
132            mode
133        }
134        None => vm_config::PermissionMode::Deny,
135    }
136}
137
138fn fs_rule_matches(
139    rule: &vm_config::FsPermissionRule,
140    operation: &str,
141    resource: Option<&str>,
142) -> bool {
143    let operations_match = permission_operation_matches(&rule.operations, operation);
144    let paths_match = permission_resource_matches(&rule.paths, resource);
145    operations_match && paths_match
146}
147
148fn pattern_rule_matches(
149    rule: &vm_config::PatternPermissionRule,
150    operation: &str,
151    resource: Option<&str>,
152) -> bool {
153    let operations_match = permission_operation_matches(&rule.operations, operation);
154    let patterns_match = permission_resource_matches(&rule.patterns, resource);
155    operations_match && patterns_match
156}
157
158fn permission_operation_matches(candidates: &[String], operation: &str) -> bool {
159    candidates
160        .iter()
161        .any(|candidate| candidate == "*" || candidate == operation)
162}
163
164fn permission_resource_matches(patterns: &[String], resource: Option<&str>) -> bool {
165    resource.is_some_and(|value| {
166        patterns
167            .iter()
168            .any(|pattern| permission_glob_matches(pattern, value))
169    })
170}
171
172pub fn validate_permissions_policy(
173    permissions: &vm_config::PermissionsPolicy,
174) -> Result<(), SidecarCoreError> {
175    if let Some(scope) = permissions.fs.as_ref() {
176        validate_fs_permission_scope("fs", scope)?;
177    }
178    if let Some(scope) = permissions.network.as_ref() {
179        validate_pattern_permission_scope("network", scope)?;
180    }
181    if let Some(scope) = permissions.child_process.as_ref() {
182        validate_pattern_permission_scope("child_process", scope)?;
183    }
184    if let Some(scope) = permissions.process.as_ref() {
185        validate_pattern_permission_scope("process", scope)?;
186    }
187    if let Some(scope) = permissions.env.as_ref() {
188        validate_pattern_permission_scope("env", scope)?;
189    }
190    if let Some(scope) = permissions.binding.as_ref() {
191        validate_pattern_permission_scope("binding", scope)?;
192    }
193    Ok(())
194}
195
196fn validate_fs_permission_scope(
197    domain: &str,
198    scope: &vm_config::FsPermissionScope,
199) -> Result<(), SidecarCoreError> {
200    let vm_config::FsPermissionScope::Rules(rule_set) = scope else {
201        return Ok(());
202    };
203
204    for (index, rule) in rule_set.rules.iter().enumerate() {
205        validate_permission_rule_field(
206            &rule.operations,
207            &format!("{domain}.rules[{index}].operations"),
208        )?;
209        validate_permission_rule_field(&rule.paths, &format!("{domain}.rules[{index}].paths"))?;
210    }
211
212    Ok(())
213}
214
215fn validate_pattern_permission_scope(
216    domain: &str,
217    scope: &vm_config::PatternPermissionScope,
218) -> Result<(), SidecarCoreError> {
219    let vm_config::PatternPermissionScope::Rules(rule_set) = scope else {
220        return Ok(());
221    };
222
223    for (index, rule) in rule_set.rules.iter().enumerate() {
224        validate_permission_rule_field(
225            &rule.operations,
226            &format!("{domain}.rules[{index}].operations"),
227        )?;
228        validate_permission_rule_field(
229            &rule.patterns,
230            &format!("{domain}.rules[{index}].patterns"),
231        )?;
232    }
233
234    Ok(())
235}
236
237fn validate_permission_rule_field(values: &[String], field: &str) -> Result<(), SidecarCoreError> {
238    if values.is_empty() {
239        return Err(SidecarCoreError::new(format!(
240            "invalid permissions policy: {field} must not be empty; use [\"*\"] for wildcard"
241        )));
242    }
243    Ok(())
244}
245
246fn capability_operation<'a>(capability: &'a str, domain: &str) -> &'a str {
247    capability
248        .strip_prefix(domain)
249        .and_then(|value| value.strip_prefix('.'))
250        .unwrap_or("")
251}
252
253pub fn permission_mode_to_kernel_decision(
254    mode: vm_config::PermissionMode,
255    capability: &str,
256) -> PermissionDecision {
257    match mode {
258        vm_config::PermissionMode::Allow => PermissionDecision::allow(),
259        vm_config::PermissionMode::Ask => {
260            PermissionDecision::deny(format!("permission prompt required for {capability}"))
261        }
262        vm_config::PermissionMode::Deny => {
263            PermissionDecision::deny(format!("blocked by {capability} policy"))
264        }
265    }
266}
267
268pub fn permissions_from_policy(policy: vm_config::PermissionsPolicy) -> Permissions {
269    let fs_policy = Arc::new(policy.clone());
270    let network_policy = Arc::new(policy.clone());
271    let child_process_policy = Arc::new(policy.clone());
272    let env_policy = Arc::new(policy);
273
274    Permissions {
275        filesystem: Some(Arc::new(move |request: &FsAccessRequest| {
276            let capability = fs_permission_capability(request.op);
277            permission_mode_to_kernel_decision(
278                evaluate_permissions_policy(&fs_policy, "fs", capability, Some(&request.path)),
279                capability,
280            )
281        })),
282        network: Some(Arc::new(move |request: &NetworkAccessRequest| {
283            let capability = network_permission_capability(request.op);
284            permission_mode_to_kernel_decision(
285                evaluate_permissions_policy(
286                    &network_policy,
287                    "network",
288                    capability,
289                    Some(&request.resource),
290                ),
291                capability,
292            )
293        })),
294        child_process: Some(Arc::new(move |request: &CommandAccessRequest| {
295            let capability = "child_process.spawn";
296            permission_mode_to_kernel_decision(
297                evaluate_permissions_policy(
298                    &child_process_policy,
299                    "child_process",
300                    capability,
301                    Some(&request.command),
302                ),
303                capability,
304            )
305        })),
306        environment: Some(Arc::new(move |request: &EnvAccessRequest| {
307            let capability = environment_permission_capability(request.op);
308            permission_mode_to_kernel_decision(
309                evaluate_permissions_policy(&env_policy, "env", capability, Some(&request.key)),
310                capability,
311            )
312        })),
313    }
314}
315
316pub fn fs_permission_capability(operation: FsOperation) -> &'static str {
317    match operation {
318        FsOperation::Read => filesystem_permission_capability(FilesystemAccess::Read),
319        FsOperation::Write => filesystem_permission_capability(FilesystemAccess::Write),
320        FsOperation::Mkdir | FsOperation::CreateDir => {
321            filesystem_permission_capability(FilesystemAccess::CreateDir)
322        }
323        FsOperation::ReadDir => filesystem_permission_capability(FilesystemAccess::ReadDir),
324        FsOperation::Stat | FsOperation::Exists => {
325            filesystem_permission_capability(FilesystemAccess::Stat)
326        }
327        FsOperation::Remove => filesystem_permission_capability(FilesystemAccess::Remove),
328        FsOperation::Rename => filesystem_permission_capability(FilesystemAccess::Rename),
329        FsOperation::Symlink => filesystem_permission_capability(FilesystemAccess::Symlink),
330        FsOperation::ReadLink => filesystem_permission_capability(FilesystemAccess::ReadLink),
331        FsOperation::Link | FsOperation::Chmod | FsOperation::Chown | FsOperation::Utimes => {
332            filesystem_permission_capability(FilesystemAccess::Write)
333        }
334        FsOperation::Truncate => filesystem_permission_capability(FilesystemAccess::Truncate),
335        FsOperation::MountSensitive => "fs.mount_sensitive",
336    }
337}
338
339pub fn filesystem_permission_capability(access: FilesystemAccess) -> &'static str {
340    match access {
341        FilesystemAccess::Read => "fs.read",
342        FilesystemAccess::Write => "fs.write",
343        FilesystemAccess::Stat => "fs.stat",
344        FilesystemAccess::ReadDir => "fs.readdir",
345        FilesystemAccess::CreateDir => "fs.create_dir",
346        FilesystemAccess::Remove => "fs.rm",
347        FilesystemAccess::Rename => "fs.rename",
348        FilesystemAccess::Symlink => "fs.symlink",
349        FilesystemAccess::ReadLink => "fs.readlink",
350        FilesystemAccess::Chmod => "fs.chmod",
351        FilesystemAccess::Truncate => "fs.truncate",
352    }
353}
354
355pub fn network_permission_capability(operation: NetworkOperation) -> &'static str {
356    match operation {
357        NetworkOperation::Fetch => "network.fetch",
358        NetworkOperation::Http => "network.http",
359        NetworkOperation::Dns => "network.dns",
360        NetworkOperation::Listen => "network.listen",
361    }
362}
363
364pub fn environment_permission_capability(operation: EnvironmentOperation) -> &'static str {
365    match operation {
366        EnvironmentOperation::Read => "env.read",
367        EnvironmentOperation::Write => "env.write",
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn permissions_default_to_deny() {
377        let policy = vm_config::PermissionsPolicy {
378            fs: None,
379            network: None,
380            child_process: None,
381            process: None,
382            env: None,
383            binding: None,
384        };
385
386        assert_eq!(
387            evaluate_permissions_policy(&policy, "fs", "fs.read", Some("/tmp/a")),
388            vm_config::PermissionMode::Deny
389        );
390    }
391
392    #[test]
393    fn permissions_default_to_deny_for_every_domain() {
394        let policy = vm_config::PermissionsPolicy {
395            fs: None,
396            network: None,
397            child_process: None,
398            process: None,
399            env: None,
400            binding: None,
401        };
402
403        for (domain, capability, resource) in [
404            ("fs", "fs.read", "/workspace/file.txt"),
405            ("network", "network.http", "example.com:443"),
406            ("child_process", "child_process.spawn", "sh"),
407            ("process", "process.kill", "123"),
408            ("env", "env.read", "TOKEN"),
409            ("binding", "binding.call", "shell"),
410        ] {
411            assert_eq!(
412                evaluate_permissions_policy(&policy, domain, capability, Some(resource)),
413                vm_config::PermissionMode::Deny,
414                "{domain} should default to deny",
415            );
416        }
417    }
418
419    #[test]
420    fn ask_permission_modes_become_kernel_denials() {
421        let decision =
422            permission_mode_to_kernel_decision(vm_config::PermissionMode::Ask, "network.http");
423
424        assert!(!decision.allow);
425        assert_eq!(
426            decision.reason.as_deref(),
427            Some("permission prompt required for network.http")
428        );
429    }
430
431    #[test]
432    fn ask_permission_modes_deny_every_policy_domain() {
433        let policy = vm_config::PermissionsPolicy {
434            fs: Some(vm_config::FsPermissionScope::Mode(
435                vm_config::PermissionMode::Ask,
436            )),
437            network: Some(vm_config::PatternPermissionScope::Mode(
438                vm_config::PermissionMode::Ask,
439            )),
440            child_process: Some(vm_config::PatternPermissionScope::Mode(
441                vm_config::PermissionMode::Ask,
442            )),
443            process: Some(vm_config::PatternPermissionScope::Mode(
444                vm_config::PermissionMode::Ask,
445            )),
446            env: Some(vm_config::PatternPermissionScope::Mode(
447                vm_config::PermissionMode::Ask,
448            )),
449            binding: Some(vm_config::PatternPermissionScope::Mode(
450                vm_config::PermissionMode::Ask,
451            )),
452        };
453
454        for (domain, capability, resource) in [
455            ("fs", "fs.read", "/workspace/file.txt"),
456            ("network", "network.http", "example.com:443"),
457            ("child_process", "child_process.spawn", "sh"),
458            ("process", "process.kill", "123"),
459            ("env", "env.read", "TOKEN"),
460            ("binding", "binding.call", "shell"),
461        ] {
462            let mode = evaluate_permissions_policy(&policy, domain, capability, Some(resource));
463            assert_eq!(
464                mode,
465                vm_config::PermissionMode::Ask,
466                "{domain} should preserve Ask until kernel-decision mapping",
467            );
468            let decision = permission_mode_to_kernel_decision(mode, capability);
469            assert!(
470                !decision.allow,
471                "{domain} Ask should map to a kernel denial",
472            );
473            assert_eq!(
474                decision.reason.as_deref(),
475                Some(format!("permission prompt required for {capability}").as_str()),
476                "{domain} Ask denial should explain the denied prompt",
477            );
478        }
479    }
480
481    #[test]
482    fn ask_permission_modes_deny_every_kernel_callback_domain() {
483        let permissions = permissions_from_policy(vm_config::PermissionsPolicy {
484            fs: Some(vm_config::FsPermissionScope::Mode(
485                vm_config::PermissionMode::Ask,
486            )),
487            network: Some(vm_config::PatternPermissionScope::Mode(
488                vm_config::PermissionMode::Ask,
489            )),
490            child_process: Some(vm_config::PatternPermissionScope::Mode(
491                vm_config::PermissionMode::Ask,
492            )),
493            process: None,
494            env: Some(vm_config::PatternPermissionScope::Mode(
495                vm_config::PermissionMode::Ask,
496            )),
497            binding: None,
498        });
499
500        assert!(
501            !permissions.filesystem.expect("filesystem callback")(&FsAccessRequest {
502                vm_id: String::from("vm"),
503                op: FsOperation::Read,
504                path: String::from("/workspace/file.txt"),
505            })
506            .allow
507        );
508        assert!(
509            !permissions.network.expect("network callback")(&NetworkAccessRequest {
510                vm_id: String::from("vm"),
511                op: NetworkOperation::Http,
512                resource: String::from("example.com:443"),
513            })
514            .allow
515        );
516        assert!(
517            !permissions.child_process.expect("child_process callback")(&CommandAccessRequest {
518                vm_id: String::from("vm"),
519                command: String::from("sh"),
520                args: Vec::new(),
521                cwd: None,
522                env: Default::default(),
523            })
524            .allow
525        );
526        assert!(
527            !permissions.environment.expect("environment callback")(&EnvAccessRequest {
528                vm_id: String::from("vm"),
529                op: EnvironmentOperation::Read,
530                key: String::from("TOKEN"),
531                value: None,
532            })
533            .allow
534        );
535    }
536
537    #[test]
538    fn permissions_from_policy_builds_kernel_callbacks() {
539        let policy = vm_config::PermissionsPolicy {
540            fs: Some(vm_config::FsPermissionScope::Rules(
541                vm_config::FsPermissionRuleSet {
542                    default: Some(vm_config::PermissionMode::Deny),
543                    rules: vec![vm_config::FsPermissionRule {
544                        mode: vm_config::PermissionMode::Allow,
545                        operations: vec![String::from("read")],
546                        paths: vec![String::from("/workspace/**")],
547                    }],
548                },
549            )),
550            network: None,
551            child_process: None,
552            process: None,
553            env: None,
554            binding: None,
555        };
556
557        let permissions = permissions_from_policy(policy);
558        let check = permissions.filesystem.expect("filesystem callback");
559
560        assert!(
561            check(&FsAccessRequest {
562                vm_id: String::from("vm"),
563                op: FsOperation::Read,
564                path: String::from("/workspace/file.txt"),
565            })
566            .allow
567        );
568        assert!(
569            !check(&FsAccessRequest {
570                vm_id: String::from("vm"),
571                op: FsOperation::Read,
572                path: String::from("/secrets/file.txt"),
573            })
574            .allow
575        );
576    }
577
578    #[test]
579    fn last_matching_rule_wins() {
580        let policy = vm_config::PermissionsPolicy {
581            fs: Some(vm_config::FsPermissionScope::Rules(
582                vm_config::FsPermissionRuleSet {
583                    default: Some(vm_config::PermissionMode::Deny),
584                    rules: vec![
585                        vm_config::FsPermissionRule {
586                            mode: vm_config::PermissionMode::Allow,
587                            operations: vec![String::from("read")],
588                            paths: vec![String::from("/workspace/**")],
589                        },
590                        vm_config::FsPermissionRule {
591                            mode: vm_config::PermissionMode::Deny,
592                            operations: vec![String::from("read")],
593                            paths: vec![String::from("/workspace/secrets/**")],
594                        },
595                    ],
596                },
597            )),
598            network: None,
599            child_process: None,
600            process: None,
601            env: None,
602            binding: None,
603        };
604
605        assert_eq!(
606            evaluate_permissions_policy(&policy, "fs", "fs.read", Some("/workspace/secrets/key")),
607            vm_config::PermissionMode::Deny
608        );
609    }
610
611    #[test]
612    fn empty_rule_fields_are_rejected() {
613        let policy = vm_config::PermissionsPolicy {
614            fs: Some(vm_config::FsPermissionScope::Rules(
615                vm_config::FsPermissionRuleSet {
616                    default: None,
617                    rules: vec![vm_config::FsPermissionRule {
618                        mode: vm_config::PermissionMode::Allow,
619                        operations: Vec::new(),
620                        paths: vec![String::from("*")],
621                    }],
622                },
623            )),
624            network: None,
625            child_process: None,
626            process: None,
627            env: None,
628            binding: None,
629        };
630
631        let error = validate_permissions_policy(&policy).expect_err("policy should be invalid");
632        assert!(error
633            .to_string()
634            .contains("fs.rules[0].operations must not be empty"));
635    }
636}