Skip to main content

greentic_component/
security.rs

1use std::collections::HashSet;
2
3use crate::capabilities::CapabilityError;
4use crate::capabilities::{
5    Capabilities, FilesystemCapabilities, FilesystemMode, HostCapabilities, TelemetryScope,
6    WasiCapabilities,
7};
8use crate::manifest::ComponentManifest;
9
10/// Host profile describing the maximum capabilities granted to a component.
11#[derive(Debug, Clone, Default)]
12pub struct Profile {
13    pub allowed: Capabilities,
14}
15
16impl Profile {
17    pub fn new(allowed: Capabilities) -> Self {
18        Self { allowed }
19    }
20}
21
22pub fn enforce_capabilities(
23    manifest: &ComponentManifest,
24    profile: Profile,
25) -> Result<(), CapabilityError> {
26    ensure_wasi(&manifest.capabilities.wasi, &profile.allowed.wasi)?;
27    ensure_host(&manifest.capabilities.host, &profile.allowed.host)
28}
29
30fn ensure_wasi(
31    requested: &WasiCapabilities,
32    allowed: &WasiCapabilities,
33) -> Result<(), CapabilityError> {
34    if let Some(fs) = &requested.filesystem {
35        let policy = allowed.filesystem.as_ref().ok_or_else(|| {
36            CapabilityError::invalid("wasi.filesystem", "filesystem access denied")
37        })?;
38        ensure_filesystem(fs, policy)?;
39    }
40
41    if let Some(env) = &requested.env {
42        let policy = allowed
43            .env
44            .as_ref()
45            .ok_or_else(|| CapabilityError::invalid("wasi.env", "environment access denied"))?;
46        let allowed_vars: HashSet<_> = policy.allow.iter().collect();
47        for var in &env.allow {
48            if !allowed_vars.contains(var) {
49                return Err(CapabilityError::invalid(
50                    "wasi.env.allow",
51                    format!("env `{var}` not permitted by profile"),
52                ));
53            }
54        }
55    }
56
57    if requested.random && !allowed.random {
58        return Err(CapabilityError::invalid(
59            "wasi.random",
60            "profile denies random number generation",
61        ));
62    }
63    if requested.clocks && !allowed.clocks {
64        return Err(CapabilityError::invalid(
65            "wasi.clocks",
66            "profile denies clock access",
67        ));
68    }
69
70    Ok(())
71}
72
73fn ensure_filesystem(
74    requested: &FilesystemCapabilities,
75    allowed: &FilesystemCapabilities,
76) -> Result<(), CapabilityError> {
77    if mode_rank(&requested.mode) > mode_rank(&allowed.mode) {
78        return Err(CapabilityError::invalid(
79            "wasi.filesystem.mode",
80            "requested mode exceeds profile allowance",
81        ));
82    }
83
84    let allowed_mounts: HashSet<_> = allowed
85        .mounts
86        .iter()
87        .map(|mount| (&mount.name, &mount.host_class, &mount.guest_path))
88        .collect();
89    for mount in &requested.mounts {
90        let key = (&mount.name, &mount.host_class, &mount.guest_path);
91        if !allowed_mounts.contains(&key) {
92            return Err(CapabilityError::invalid(
93                "wasi.filesystem.mounts",
94                format!("mount `{}` is not available in this profile", mount.name),
95            ));
96        }
97    }
98    Ok(())
99}
100
101fn mode_rank(mode: &FilesystemMode) -> u8 {
102    match mode {
103        FilesystemMode::None => 0,
104        FilesystemMode::ReadOnly => 1,
105        FilesystemMode::Sandbox => 2,
106    }
107}
108
109fn ensure_host(
110    requested: &HostCapabilities,
111    allowed: &HostCapabilities,
112) -> Result<(), CapabilityError> {
113    if let Some(secrets) = &requested.secrets {
114        let policy = allowed
115            .secrets
116            .as_ref()
117            .ok_or_else(|| CapabilityError::invalid("host.secrets", "secrets access denied"))?;
118        let allowed_set: HashSet<_> = policy.required.iter().map(|req| req.key.as_str()).collect();
119        for key in secrets.required.iter().map(|req| req.key.as_str()) {
120            if !allowed_set.contains(key) {
121                return Err(CapabilityError::invalid(
122                    "host.secrets.required",
123                    format!("secret `{key}` is not available"),
124                ));
125            }
126        }
127    }
128
129    if let Some(state) = &requested.state {
130        let policy = allowed
131            .state
132            .as_ref()
133            .ok_or_else(|| CapabilityError::invalid("host.state", "state access denied"))?;
134        if state.read && !policy.read {
135            return Err(CapabilityError::invalid(
136                "host.state.read",
137                "profile denies state reads",
138            ));
139        }
140        if state.write && !policy.write {
141            return Err(CapabilityError::invalid(
142                "host.state.write",
143                "profile denies state writes",
144            ));
145        }
146    }
147
148    ensure_io_capability(
149        requested
150            .messaging
151            .as_ref()
152            .map(|m| (m.inbound, m.outbound)),
153        allowed.messaging.as_ref().map(|m| (m.inbound, m.outbound)),
154        "host.messaging",
155    )?;
156    ensure_io_capability(
157        requested.events.as_ref().map(|m| (m.inbound, m.outbound)),
158        allowed.events.as_ref().map(|m| (m.inbound, m.outbound)),
159        "host.events",
160    )?;
161    ensure_io_capability(
162        requested.http.as_ref().map(|h| (h.client, h.server)),
163        allowed.http.as_ref().map(|h| (h.client, h.server)),
164        "host.http",
165    )?;
166
167    if let Some(telemetry) = &requested.telemetry {
168        let policy = allowed
169            .telemetry
170            .as_ref()
171            .ok_or_else(|| CapabilityError::invalid("host.telemetry", "telemetry access denied"))?;
172        if !telemetry_scope_allowed(&policy.scope, &telemetry.scope) {
173            return Err(CapabilityError::invalid(
174                "host.telemetry.scope",
175                format!(
176                    "requested scope `{:?}` exceeds profile allowance `{:?}`",
177                    telemetry.scope, policy.scope
178                ),
179            ));
180        }
181    }
182
183    if let Some(iac) = &requested.iac {
184        let policy = allowed
185            .iac
186            .as_ref()
187            .ok_or_else(|| CapabilityError::invalid("host.iac", "iac access denied"))?;
188        if iac.write_templates && !policy.write_templates {
189            return Err(CapabilityError::invalid(
190                "host.iac.write_templates",
191                "profile denies template writes",
192            ));
193        }
194        if iac.execute_plans && !policy.execute_plans {
195            return Err(CapabilityError::invalid(
196                "host.iac.execute_plans",
197                "profile denies plan execution",
198            ));
199        }
200    }
201
202    Ok(())
203}
204
205fn ensure_io_capability(
206    requested: Option<(bool, bool)>,
207    allowed: Option<(bool, bool)>,
208    label: &'static str,
209) -> Result<(), CapabilityError> {
210    if let Some((req_in, req_out)) = requested {
211        let Some((allow_in, allow_out)) = allowed else {
212            return Err(CapabilityError::invalid(
213                label,
214                "profile denies this capability",
215            ));
216        };
217        if req_in && !allow_in {
218            return Err(CapabilityError::invalid(
219                label,
220                "inbound access denied by profile",
221            ));
222        }
223        if req_out && !allow_out {
224            return Err(CapabilityError::invalid(
225                label,
226                "outbound access denied by profile",
227            ));
228        }
229    }
230    Ok(())
231}
232
233fn telemetry_scope_allowed(allowed: &TelemetryScope, requested: &TelemetryScope) -> bool {
234    scope_rank(allowed) >= scope_rank(requested)
235}
236
237fn scope_rank(scope: &TelemetryScope) -> u8 {
238    match scope {
239        TelemetryScope::Tenant => 0,
240        TelemetryScope::Pack => 1,
241        TelemetryScope::Node => 2,
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::manifest::parse_manifest;
249    use greentic_types::component::{
250        ComponentCapabilities, EnvCapabilities, FilesystemMount, HttpCapabilities, IaCCapabilities,
251        SecretsCapabilities, StateCapabilities, TelemetryCapabilities,
252    };
253    use greentic_types::{SecretFormat, SecretKey, SecretRequirement, SecretScope};
254    use serde_json::json;
255
256    fn manifest_with_caps(capabilities: Capabilities) -> ComponentManifest {
257        const DUMMY_HASH: &str =
258            "blake3:0000000000000000000000000000000000000000000000000000000000000000";
259        parse_manifest(&json!({
260            "id": "com.greentic.test.component",
261            "name": "test",
262            "version": "0.1.0",
263            "world": "greentic:component/component@0.6.0",
264            "describe_export": "describe",
265            "operations": [{
266                "name": "run",
267                "input_schema": {
268                    "type": "object",
269                    "properties": {},
270                    "required": [],
271                    "additionalProperties": false
272                },
273                "output_schema": {
274                    "type": "object",
275                    "properties": {},
276                    "required": [],
277                    "additionalProperties": false
278                }
279            }],
280            "default_operation": "run",
281            "config_schema": {
282                "type": "object",
283                "properties": {},
284                "required": [],
285                "additionalProperties": false
286            },
287            "supports": ["messaging"],
288            "profiles": { "default": "stateless", "supported": ["stateless"] },
289            "secret_requirements": [],
290            "capabilities": capabilities,
291            "limits": { "memory_mb": 64, "wall_time_ms": 1000 },
292            "artifacts": { "component_wasm": "component.wasm" },
293            "hashes": { "component_wasm": DUMMY_HASH },
294            "dev_flows": {
295                "default": {
296                    "format": "flow-ir-json",
297                    "graph": {
298                        "nodes": [{ "id": "start", "type": "start" }, { "id": "end", "type": "end" }],
299                        "edges": [{ "from": "start", "to": "end" }]
300                    }
301                }
302            }
303        })
304        .to_string())
305        .expect("manifest fixture")
306    }
307
308    fn baseline_caps() -> Capabilities {
309        ComponentCapabilities {
310            wasi: WasiCapabilities {
311                filesystem: None,
312                env: None,
313                random: false,
314                clocks: false,
315            },
316            host: HostCapabilities {
317                messaging: None,
318                events: None,
319                http: None,
320                secrets: None,
321                state: None,
322                telemetry: None,
323                iac: None,
324            },
325        }
326    }
327
328    fn secret_requirement(key: &str) -> SecretRequirement {
329        let mut requirement = SecretRequirement::default();
330        requirement.key = SecretKey::new(key).expect("valid secret key");
331        requirement.required = true;
332        requirement.scope = Some(SecretScope {
333            env: "dev".into(),
334            tenant: "tenant-a".into(),
335            team: None,
336        });
337        requirement.format = Some(SecretFormat::Text);
338        requirement
339    }
340
341    #[test]
342    fn denies_unlisted_environment_variables() {
343        let mut requested = baseline_caps();
344        requested.wasi.env = Some(EnvCapabilities {
345            allow: vec!["SAFE".into(), "UNSAFE".into()],
346        });
347        let manifest = manifest_with_caps(requested);
348
349        let mut allowed = baseline_caps();
350        allowed.wasi.env = Some(EnvCapabilities {
351            allow: vec!["SAFE".into()],
352        });
353
354        let err = enforce_capabilities(&manifest, Profile::new(allowed))
355            .expect_err("profile should reject undeclared env var");
356        assert_eq!(err.path, "wasi.env.allow");
357        assert!(err.message.contains("UNSAFE"));
358    }
359
360    #[test]
361    fn denies_telemetry_scope_escalation() {
362        let mut requested = baseline_caps();
363        requested.host.telemetry = Some(TelemetryCapabilities {
364            scope: TelemetryScope::Node,
365        });
366        let manifest = manifest_with_caps(requested);
367
368        let mut allowed = baseline_caps();
369        allowed.host.telemetry = Some(TelemetryCapabilities {
370            scope: TelemetryScope::Tenant,
371        });
372
373        let err = enforce_capabilities(&manifest, Profile::new(allowed))
374            .expect_err("tenant-only telemetry should reject node scope");
375        assert_eq!(err.path, "host.telemetry.scope");
376    }
377
378    #[test]
379    fn denies_http_server_when_profile_only_allows_client() {
380        let mut requested = baseline_caps();
381        requested.host.http = Some(HttpCapabilities {
382            client: false,
383            server: true,
384        });
385        let manifest = manifest_with_caps(requested);
386
387        let mut allowed = baseline_caps();
388        allowed.host.http = Some(HttpCapabilities {
389            client: true,
390            server: false,
391        });
392
393        let err = enforce_capabilities(&manifest, Profile::new(allowed))
394            .expect_err("server capability should be denied");
395        assert_eq!(err.path, "host.http");
396        assert!(err.message.contains("outbound access denied") || err.message.contains("inbound"));
397    }
398
399    #[test]
400    fn denies_state_write_when_profile_is_read_only() {
401        let mut requested = baseline_caps();
402        requested.host.state = Some(StateCapabilities {
403            read: true,
404            write: true,
405        });
406        let manifest = manifest_with_caps(requested);
407
408        let mut allowed = baseline_caps();
409        allowed.host.state = Some(StateCapabilities {
410            read: true,
411            write: false,
412        });
413
414        let err = enforce_capabilities(&manifest, Profile::new(allowed))
415            .expect_err("write access should be denied");
416        assert_eq!(err.path, "host.state.write");
417    }
418
419    #[test]
420    fn denies_random_and_clock_access_when_profile_disallows_them() {
421        let mut requested = baseline_caps();
422        requested.wasi.random = true;
423        requested.wasi.clocks = true;
424        let manifest = manifest_with_caps(requested);
425
426        let err = enforce_capabilities(&manifest, Profile::new(baseline_caps()))
427            .expect_err("random access should be denied first");
428        assert_eq!(err.path, "wasi.random");
429
430        let mut requested = baseline_caps();
431        requested.wasi.clocks = true;
432        let manifest = manifest_with_caps(requested);
433        let err = enforce_capabilities(&manifest, Profile::new(baseline_caps()))
434            .expect_err("clock access should be denied");
435        assert_eq!(err.path, "wasi.clocks");
436    }
437
438    #[test]
439    fn denies_filesystem_mode_escalation() {
440        let mount = FilesystemMount {
441            name: "data".into(),
442            host_class: "data".into(),
443            guest_path: "/data".into(),
444        };
445        let mut requested = baseline_caps();
446        requested.wasi.filesystem = Some(FilesystemCapabilities {
447            mode: FilesystemMode::Sandbox,
448            mounts: vec![mount.clone()],
449        });
450        let manifest = manifest_with_caps(requested);
451
452        let mut allowed = baseline_caps();
453        allowed.wasi.filesystem = Some(FilesystemCapabilities {
454            mode: FilesystemMode::ReadOnly,
455            mounts: vec![mount],
456        });
457
458        let err = enforce_capabilities(&manifest, Profile::new(allowed))
459            .expect_err("sandbox access should exceed read-only profile");
460        assert_eq!(err.path, "wasi.filesystem.mode");
461    }
462
463    #[test]
464    fn denies_secret_access_when_profile_has_no_secret_capability() {
465        let mut requested = baseline_caps();
466        requested.host.secrets = Some(SecretsCapabilities {
467            required: vec![secret_requirement("api-key")],
468        });
469        let manifest = manifest_with_caps(requested);
470
471        let err = enforce_capabilities(&manifest, Profile::new(baseline_caps()))
472            .expect_err("secrets should be denied");
473
474        assert_eq!(err.path, "host.secrets");
475    }
476
477    #[test]
478    fn denies_iac_plan_execution_when_profile_forbids_it() {
479        let mut requested = baseline_caps();
480        requested.host.iac = Some(IaCCapabilities {
481            write_templates: false,
482            execute_plans: true,
483        });
484        let manifest = manifest_with_caps(requested);
485
486        let mut allowed = baseline_caps();
487        allowed.host.iac = Some(IaCCapabilities {
488            write_templates: true,
489            execute_plans: false,
490        });
491
492        let err = enforce_capabilities(&manifest, Profile::new(allowed))
493            .expect_err("plan execution should be denied");
494
495        assert_eq!(err.path, "host.iac.execute_plans");
496    }
497}