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}