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#[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}