1use soma_provider_core::{
4 BrokerCapability, BrowserCapability, CapabilityGrant, EnvCapability, FilesystemCapability,
5 GitHubCapability, HostCapabilities, NetworkCapability, TerminalCapability,
6};
7
8use crate::provider_errors::ProviderError;
9
10#[derive(Debug, Clone, Default)]
13pub struct CapabilityBroker {
14 grants: Vec<(String, CapabilityGrant)>,
15}
16
17impl CapabilityBroker {
18 pub fn new(grants: Vec<(String, CapabilityGrant)>) -> Self {
20 Self { grants }
21 }
22
23 pub fn default_deny() -> Self {
25 Self::default()
26 }
27
28 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}