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}