Skip to main content

soma_application/
capabilities.rs

1//! Capability brokering: the [`CapabilityBroker`] decides which host
2//! capabilities a provider dispatch may use, defaulting to deny.
3use soma_provider_core::{
4    BrokerCapability, BrowserCapability, CapabilityGrant, EnvCapability, FilesystemCapability,
5    GitHubCapability, HostCapabilities, NetworkCapability, TerminalCapability,
6};
7
8use crate::provider_errors::ProviderError;
9
10/// Checks provider-requested host capabilities against a fixed set of policy
11/// grants, denying any capability that is not explicitly allowed.
12#[derive(Debug, Clone, Default)]
13pub struct CapabilityBroker {
14    grants: Vec<(String, CapabilityGrant)>,
15}
16
17impl CapabilityBroker {
18    /// Builds a broker backed by the given policy grants.
19    pub fn new(grants: Vec<(String, CapabilityGrant)>) -> Self {
20        Self { grants }
21    }
22
23    /// Builds a broker with no grants, denying every host capability.
24    pub fn default_deny() -> Self {
25        Self::default()
26    }
27
28    /// Authorizes a provider action's requested host capabilities, returning
29    /// an error for the first requested capability no grant covers.
30    pub fn authorize(
31        &self,
32        provider: &str,
33        action: &str,
34        requested: &HostCapabilities,
35    ) -> Result<(), ProviderError> {
36        if let Some(capability) = enabled_filesystem(requested) {
37            let granted = self.grants.iter().any(|(owner, grant)| {
38                owner == provider
39                    && match grant {
40                        CapabilityGrant::Filesystem {
41                            read_roots,
42                            write_roots,
43                        } => {
44                            all_paths_allowed(&capability.read_roots, read_roots)
45                                && all_paths_allowed(&capability.write_roots, write_roots)
46                        }
47                        _ => false,
48                    }
49            });
50            if !granted {
51                return Err(denied(provider, action, "filesystem"));
52            }
53        }
54        if let Some(capability) = enabled_network(requested) {
55            let granted = self.grants.iter().any(|(owner, grant)| {
56                owner == provider
57                    && match grant {
58                        CapabilityGrant::Network { allowed_hosts } => {
59                            all_items_allowed(&capability.allowed_hosts, allowed_hosts)
60                        }
61                        _ => false,
62                    }
63            });
64            if !granted {
65                return Err(denied(provider, action, "network"));
66            }
67        }
68        if let Some(capability) = enabled_env(requested) {
69            let granted = self.grants.iter().any(|(owner, grant)| {
70                owner == provider
71                    && match grant {
72                        CapabilityGrant::Env { allowed } => {
73                            all_items_allowed(&capability.allowed, allowed)
74                        }
75                        _ => false,
76                    }
77            });
78            if !granted {
79                return Err(denied(provider, action, "env"));
80            }
81        }
82        if let Some(capability) = enabled_terminal(requested) {
83            let granted = self.grants.iter().any(|(owner, grant)| {
84                owner == provider
85                    && match grant {
86                        CapabilityGrant::Terminal {
87                            working_dir,
88                            allowlist,
89                        } => {
90                            working_dir_allows(
91                                capability.working_dir.as_deref(),
92                                working_dir.as_deref(),
93                            ) && all_items_allowed(&capability.allowlist, allowlist)
94                        }
95                        _ => false,
96                    }
97            });
98            if !granted {
99                return Err(denied(provider, action, "terminal"));
100            }
101        }
102        if let Some(capability) = enabled_browser(requested) {
103            let granted = self.grants.iter().any(|(owner, grant)| {
104                owner == provider
105                    && match grant {
106                        CapabilityGrant::Browser { allowed_origins } => {
107                            all_items_allowed(&capability.allowed_origins, allowed_origins)
108                        }
109                        _ => false,
110                    }
111            });
112            if !granted {
113                return Err(denied(provider, action, "browser"));
114            }
115        }
116        if let Some(capability) = enabled_github(requested) {
117            let granted = self.grants.iter().any(|(owner, grant)| {
118                owner == provider
119                    && match grant {
120                        CapabilityGrant::Github {
121                            allowed_repos,
122                            read_only,
123                        } => {
124                            all_items_allowed(&capability.allowed_repos, allowed_repos)
125                                && (capability.read_only || !read_only)
126                        }
127                        _ => false,
128                    }
129            });
130            if !granted {
131                return Err(denied(provider, action, "github"));
132            }
133        }
134        if let Some(capability) = enabled_broker(requested) {
135            let provider_scoped = capability
136                .secret_names
137                .iter()
138                .all(|name| provider_owns_name(provider, name))
139                && capability
140                    .state_namespace
141                    .as_ref()
142                    .is_none_or(|name| provider_owns_name(provider, name));
143            if !provider_scoped {
144                return Err(denied(provider, action, "broker"));
145            }
146            let granted = self.grants.iter().any(|(owner, grant)| {
147                owner == provider
148                    && match grant {
149                        CapabilityGrant::Broker {
150                            secret_names,
151                            state_read_namespaces,
152                            state_write_namespaces,
153                            allow_logging,
154                            allow_metrics,
155                            allow_progress,
156                        } => {
157                            all_items_allowed(&capability.secret_names, secret_names)
158                                && capability.state_namespace.as_ref().is_none_or(|namespace| {
159                                    state_read_namespaces.contains(namespace)
160                                        && (!capability.state_write
161                                            || state_write_namespaces.contains(namespace))
162                                })
163                                && (!capability.logging || *allow_logging)
164                                && (!capability.metrics || *allow_metrics)
165                                && (!capability.progress || *allow_progress)
166                        }
167                        _ => false,
168                    }
169            });
170            if !granted {
171                return Err(denied(provider, action, "broker"));
172            }
173        }
174        Ok(())
175    }
176}
177
178fn provider_owns_name(provider: &str, name: &str) -> bool {
179    name == provider
180        || name
181            .strip_prefix(provider)
182            .is_some_and(|suffix| suffix.starts_with('-') || suffix.starts_with('/'))
183}
184
185fn enabled_filesystem(requested: &HostCapabilities) -> Option<&FilesystemCapability> {
186    requested.filesystem.as_ref().filter(|cap| cap.enabled)
187}
188
189fn enabled_network(requested: &HostCapabilities) -> Option<&NetworkCapability> {
190    requested.network.as_ref().filter(|cap| cap.enabled)
191}
192
193fn enabled_env(requested: &HostCapabilities) -> Option<&EnvCapability> {
194    requested.env.as_ref().filter(|cap| cap.enabled)
195}
196
197fn enabled_terminal(requested: &HostCapabilities) -> Option<&TerminalCapability> {
198    requested.terminal.as_ref().filter(|cap| cap.enabled)
199}
200
201fn enabled_browser(requested: &HostCapabilities) -> Option<&BrowserCapability> {
202    requested.browser.as_ref().filter(|cap| cap.enabled)
203}
204
205fn enabled_github(requested: &HostCapabilities) -> Option<&GitHubCapability> {
206    requested.github.as_ref().filter(|cap| cap.enabled)
207}
208
209fn enabled_broker(requested: &HostCapabilities) -> Option<&BrokerCapability> {
210    requested
211        .broker
212        .as_ref()
213        .filter(|capability| capability.enabled)
214}
215
216fn all_items_allowed(requested: &[String], allowed: &[String]) -> bool {
217    requested
218        .iter()
219        .all(|item| allowed.iter().any(|grant| grant == item))
220}
221
222fn all_paths_allowed(requested: &[String], allowed: &[String]) -> bool {
223    requested
224        .iter()
225        .all(|item| allowed.iter().any(|grant| path_allows(item, grant)))
226}
227
228fn path_allows(requested: &str, grant: &str) -> bool {
229    requested == grant
230        || requested
231            .strip_prefix(grant.trim_end_matches('/'))
232            .map(|suffix| suffix.starts_with('/'))
233            .unwrap_or(false)
234}
235
236fn working_dir_allows(requested: Option<&str>, granted: Option<&str>) -> bool {
237    match (requested, granted) {
238        (None, _) => true,
239        (Some(_), None) => false,
240        (Some(requested), Some(granted)) => path_allows(requested, granted),
241    }
242}
243
244fn denied(provider: &str, action: &str, capability: &str) -> ProviderError {
245    ProviderError::new(
246        "capability_denied",
247        provider,
248        Some(action.to_owned()),
249        format!("provider requested denied {capability} capability"),
250        "Grant the specific host capability in policy or disable the provider action.",
251    )
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn broker_grants_intersect_every_requested_authority() {
260        let broker = CapabilityBroker::new(vec![(
261            "weather".to_owned(),
262            CapabilityGrant::Broker {
263                secret_names: vec!["weather-key".to_owned()],
264                state_read_namespaces: vec!["weather".to_owned()],
265                state_write_namespaces: vec!["weather".to_owned()],
266                allow_logging: true,
267                allow_metrics: false,
268                allow_progress: true,
269            },
270        )]);
271        let requested = HostCapabilities {
272            broker: Some(BrokerCapability {
273                enabled: true,
274                secret_names: vec!["weather-key".to_owned()],
275                state_namespace: Some("weather".to_owned()),
276                state_write: true,
277                logging: true,
278                metrics: false,
279                progress: true,
280            }),
281            ..HostCapabilities::default()
282        };
283        broker
284            .authorize("weather", "forecast", &requested)
285            .expect("matching provider and deployment authority is allowed");
286        assert!(
287            broker.authorize("billing", "forecast", &requested).is_err(),
288            "a deployment grant for one provider must not become global authority"
289        );
290
291        let mut excessive = requested.clone();
292        excessive.broker.as_mut().unwrap().metrics = true;
293        let error = broker
294            .authorize("weather", "forecast", &excessive)
295            .expect_err("an ungranted metric request must be denied");
296        assert_eq!(&*error.code, "capability_denied");
297
298        let read_only = CapabilityBroker::new(vec![(
299            "weather".to_owned(),
300            CapabilityGrant::Broker {
301                secret_names: Vec::new(),
302                state_read_namespaces: vec!["weather".to_owned()],
303                state_write_namespaces: Vec::new(),
304                allow_logging: false,
305                allow_metrics: false,
306                allow_progress: false,
307            },
308        )]);
309        let error = read_only
310            .authorize("weather", "forecast", &requested)
311            .expect_err("read-only deployment policy must deny state writes");
312        assert_eq!(&*error.code, "capability_denied");
313    }
314}