Skip to main content

soma_provider_adapters/
openapi.rs

1//! The generic OpenAPI provider kind: proxies a drop-in provider tool to a
2//! declared `base_url` + `path`/`method`, gated by the manifest's
3//! `capabilities.network.allowed_hosts` grant.
4//!
5//! ## Delegates to `soma-openapi`
6//!
7//! Dispatch goes through `soma_openapi::http::execute_operation_for_allowlisted_host`
8//! — the same request-building, capped response reading, and JSON
9//! parsing/status handling as `soma-openapi`'s registry-driven dispatch path
10//! (`crates/shared/openapi/src/http.rs`). This module keeps only what is
11//! specific to the drop-in-manifest shape: resolving `base_url`/`path`/
12//! `method` out of `provider.meta`/`tool.meta`, and the operator-controlled
13//! `capabilities.network.allowed_hosts` gate.
14//!
15//! That entry point (unlike `soma_openapi::http::execute_operation`, used by
16//! `soma-openapi`'s own spec-driven dispatch) skips the crate's DNS-pinned
17//! SSRF guard. This adapter's trust model is different and is itself part of
18//! the tested, documented contract: an operator explicitly allowlists hosts
19//! via `provider.capabilities.network.allowed_hosts`, and that allowlist may
20//! legitimately include loopback/private hosts (a local sidecar service, for
21//! example) — see `openapi_provider_executes_pinned_local_operation` in
22//! `apps/soma/tests/openapi_provider.rs`, which calls a plain-HTTP
23//! `127.0.0.1` server and would be rejected by the DNS-pinned guard. The
24//! allowlist check below (`validate_base_url`) is this adapter's own SSRF
25//! defense and runs before any request is dispatched. For that defense to
26//! hold, two things it does NOT rely on `soma-openapi` for are handled here:
27//! `validate_base_url` fails closed (denies dispatch) when a provider's
28//! `capabilities.network` grant is absent or disabled, rather than treating
29//! that as "no restriction needed"; and `dispatch_client` disables HTTP
30//! redirects, so an allowlisted host can't hand a request off to a
31//! non-allowlisted address via a 3xx response.
32//!
33//! A small amount of behavior differs from the pre-delegation implementation,
34//! documented here rather than hidden:
35//! - `{name}` placeholders in a declared operation `path` are now honored as
36//!   path parameters (previously inert literal text); this is additive.
37//! - An operation path that resolves outside the declared `base_url`'s own
38//!   path prefix (e.g. via `..` segments) is now rejected; previously
39//!   unchecked.
40//! - `call.params` must now be a JSON object for every method, not only
41//!   GET/DELETE; previously POST/PUT/PATCH accepted any JSON value as the
42//!   request body.
43//! - A non-2xx upstream response no longer includes the HTTP status code in
44//!   the error message (the `openapi_upstream_error` error `code` is
45//!   unchanged).
46
47use std::{sync::Arc, time::Duration};
48
49use async_trait::async_trait;
50use serde_json::Value;
51use soma_provider_core::{
52    Provider, ProviderCall, ProviderCatalog, ProviderError, ProviderOutput, ProviderTool,
53};
54use url::Url;
55
56/// reqwest 0.13's rustls backend panics without an installed crypto
57/// provider; install ring once, tolerating a provider some embedder
58/// installed earlier. (Not rmcp-specific despite the historical wording of
59/// this helper elsewhere in the workspace — this module dispatches over
60/// plain reqwest via `soma-openapi`, never through an rmcp transport.)
61fn ensure_rustls_crypto_provider() {
62    static INSTALL: std::sync::Once = std::sync::Once::new();
63    INSTALL.call_once(|| {
64        let _ = rustls::crypto::ring::default_provider().install_default();
65    });
66}
67
68/// A per-call HTTP client hardened for dispatch against an
69/// operator-allowlisted host (see `validate_base_url`): redirects are
70/// disabled so an allowlisted host cannot silently hand off the request to
71/// an arbitrary, non-allowlisted address via a 3xx response — the allowlist
72/// check in `validate_base_url` only inspects the *configured* `base_url`,
73/// so an unchecked redirect would otherwise bypass it entirely. A bounded
74/// connect/request timeout also prevents a hung upstream from blocking a
75/// call indefinitely.
76fn dispatch_client() -> Result<reqwest::Client, reqwest::Error> {
77    reqwest::Client::builder()
78        .redirect(reqwest::redirect::Policy::none())
79        .connect_timeout(Duration::from_secs(10))
80        .timeout(Duration::from_secs(30))
81        .build()
82}
83
84#[derive(Clone)]
85pub struct OpenApiProvider {
86    catalog: ProviderCatalog,
87}
88
89impl OpenApiProvider {
90    pub fn curated(catalog: ProviderCatalog) -> Self {
91        Self { catalog }
92    }
93
94    pub fn arc(catalog: ProviderCatalog) -> Arc<Self> {
95        Arc::new(Self::curated(catalog))
96    }
97}
98
99#[async_trait]
100impl Provider for OpenApiProvider {
101    fn catalog(&self) -> ProviderCatalog {
102        self.catalog.clone()
103    }
104
105    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
106        ensure_rustls_crypto_provider();
107        let tool = self.tool(&call)?;
108        let operation = OpenApiOperation::from_catalog(&self.catalog, tool, &call)?;
109        if !call.params.is_object() {
110            return Err(ProviderError::validation(
111                &self.catalog.provider.name,
112                &call.action,
113                "openapi_params_must_be_object",
114                "OpenAPI provider params must be a JSON object",
115            ));
116        }
117
118        let client = dispatch_client().map_err(|error| {
119            ProviderError::opaque_execution(&self.catalog.provider.name, &call.action, error)
120        })?;
121        let handle = soma_openapi::registry::OperationHandle {
122            operation_id: call.action.clone(),
123            method: operation.method,
124            path_template: operation.path,
125            base_url: operation.base,
126            credential: None,
127        };
128        let value = soma_openapi::http::execute_operation_for_allowlisted_host(
129            &client,
130            &handle,
131            call.params,
132        )
133        .await
134        .map_err(|error| map_openapi_error(&self.catalog.provider.name, &call.action, error))?;
135        Ok(ProviderOutput::json(value))
136    }
137}
138
139impl OpenApiProvider {
140    fn tool(&self, call: &ProviderCall) -> Result<&ProviderTool, ProviderError> {
141        self.catalog
142            .tools
143            .iter()
144            .find(|tool| tool.name == call.action)
145            .ok_or_else(|| {
146                ProviderError::validation(
147                    &self.catalog.provider.name,
148                    &call.action,
149                    "unknown_openapi_action",
150                    format!("OpenAPI provider has no action `{}`", call.action),
151                )
152            })
153    }
154}
155
156struct OpenApiOperation {
157    method: reqwest::Method,
158    base: Url,
159    path: String,
160}
161
162impl OpenApiOperation {
163    fn from_catalog(
164        catalog: &ProviderCatalog,
165        tool: &ProviderTool,
166        call: &ProviderCall,
167    ) -> Result<Self, ProviderError> {
168        let base_url = catalog
169            .meta
170            .get("openapi")
171            .and_then(|value| value.get("base_url"))
172            .and_then(Value::as_str)
173            .or_else(|| catalog.meta.get("base_url").and_then(Value::as_str))
174            .ok_or_else(|| {
175                ProviderError::validation(
176                    &catalog.provider.name,
177                    &call.action,
178                    "missing_openapi_base_url",
179                    "OpenAPI provider requires provider.meta.openapi.base_url",
180                )
181            })?;
182        let base = Url::parse(base_url).map_err(|error| {
183            ProviderError::validation(
184                &catalog.provider.name,
185                &call.action,
186                "invalid_openapi_base_url",
187                error.to_string(),
188            )
189        })?;
190        validate_base_url(catalog, &call.action, &base)?;
191
192        let operation_meta = tool.meta.get("openapi");
193        let path = operation_meta
194            .and_then(|value| value.get("path"))
195            .and_then(Value::as_str)
196            .or_else(|| tool.rest.as_ref().and_then(|rest| rest.path.as_deref()))
197            .unwrap_or("");
198        validate_relative_path(catalog, &call.action, path)?;
199
200        let method_str = operation_meta
201            .and_then(|value| value.get("method"))
202            .and_then(Value::as_str)
203            .or_else(|| tool.rest.as_ref().and_then(|rest| rest.method.as_deref()))
204            .unwrap_or("POST")
205            .to_ascii_uppercase();
206        let method = parse_supported_method(catalog, &call.action, &method_str)?;
207
208        Ok(Self {
209            method,
210            base,
211            path: path.to_owned(),
212        })
213    }
214}
215
216fn parse_supported_method(
217    catalog: &ProviderCatalog,
218    action: &str,
219    method: &str,
220) -> Result<reqwest::Method, ProviderError> {
221    match method {
222        "GET" => Ok(reqwest::Method::GET),
223        "POST" => Ok(reqwest::Method::POST),
224        "PUT" => Ok(reqwest::Method::PUT),
225        "PATCH" => Ok(reqwest::Method::PATCH),
226        "DELETE" => Ok(reqwest::Method::DELETE),
227        other => Err(ProviderError::validation(
228            &catalog.provider.name,
229            action,
230            "unsupported_openapi_method",
231            format!("unsupported OpenAPI provider method `{other}`"),
232        )),
233    }
234}
235
236fn validate_base_url(
237    catalog: &ProviderCatalog,
238    action: &str,
239    url: &Url,
240) -> Result<(), ProviderError> {
241    if !matches!(url.scheme(), "http" | "https") {
242        return Err(ProviderError::validation(
243            &catalog.provider.name,
244            action,
245            "openapi_scheme_denied",
246            "OpenAPI provider base_url must use http or https",
247        ));
248    }
249    if url.host_str().is_none() {
250        return Err(ProviderError::validation(
251            &catalog.provider.name,
252            action,
253            "openapi_host_required",
254            "OpenAPI provider base_url must include a host",
255        ));
256    }
257    // Fail closed: an OpenAPI provider always makes a network call, so an
258    // absent or disabled `capabilities.network` block must deny dispatch
259    // rather than silently skip the allowlist check. This adapter bypasses
260    // `soma-openapi`'s own DNS-pinned SSRF guard specifically because it
261    // trusts this allowlist as its replacement defense (see the module doc)
262    // — that trust model only holds if the allowlist is mandatory, not
263    // opt-in per manifest.
264    let host = url.host_str().unwrap_or_default();
265    let network = catalog.capabilities.network.as_ref().ok_or_else(|| {
266        ProviderError::validation(
267            &catalog.provider.name,
268            action,
269            "openapi_network_capability_required",
270            "OpenAPI provider requires an enabled capabilities.network.allowed_hosts grant",
271        )
272    })?;
273    if !network.enabled {
274        return Err(ProviderError::validation(
275            &catalog.provider.name,
276            action,
277            "openapi_network_capability_required",
278            "OpenAPI provider requires an enabled capabilities.network.allowed_hosts grant",
279        ));
280    }
281    if !network.allowed_hosts.iter().any(|allowed| allowed == host) {
282        return Err(ProviderError::validation(
283            &catalog.provider.name,
284            action,
285            "openapi_host_not_allowed",
286            format!("OpenAPI provider host `{host}` is not declared in allowed_hosts"),
287        ));
288    }
289    Ok(())
290}
291
292/// Rejects an operation path that is itself an absolute URL, so a manifest
293/// cannot redirect a call away from the pinned, allowlisted `base_url`. The
294/// remaining relative-path resolution (joining onto `base_url`, `{name}`
295/// path-parameter substitution, and containment under `base_url`'s own path
296/// prefix) is delegated to `soma_openapi::http`.
297fn validate_relative_path(
298    catalog: &ProviderCatalog,
299    action: &str,
300    path: &str,
301) -> Result<(), ProviderError> {
302    // Case-insensitive: `url::Url::join` (used downstream to resolve the
303    // final request URL) treats schemes case-insensitively, so a
304    // mixed/upper-case absolute URL like `HTTPS://169.254.169.254/latest`
305    // must be rejected here too — a case-sensitive check would let it slip
306    // through as "relative", get joined onto `base_url`, and send the
307    // request to a host never checked against `capabilities.network.allowed_hosts`.
308    let lower = path.to_ascii_lowercase();
309    if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("//") {
310        return Err(ProviderError::validation(
311            &catalog.provider.name,
312            action,
313            "openapi_absolute_operation_url_denied",
314            "OpenAPI provider operation paths must be relative to the pinned base_url",
315        ));
316    }
317    Ok(())
318}
319
320fn map_openapi_error(
321    provider: &str,
322    action: &str,
323    error: soma_openapi::OpenApiError,
324) -> ProviderError {
325    use soma_openapi::OpenApiError as E;
326    match &error {
327        E::InvalidPathParam { param, .. } => ProviderError::validation(
328            provider,
329            action,
330            "openapi_invalid_path_param",
331            format!("OpenAPI provider path parameter `{param}` is missing or invalid"),
332        ),
333        E::UpstreamTimeout { .. } => ProviderError::new(
334            "openapi_upstream_timeout",
335            provider,
336            Some(action.to_owned()),
337            "OpenAPI upstream request timed out",
338            "Check the provider endpoint, input, and credentials, then retry.",
339        ),
340        E::RequestBlockedPrivateAddr { .. } => ProviderError::validation(
341            provider,
342            action,
343            "openapi_operation_path_escapes_base",
344            "OpenAPI provider operation path resolved outside the pinned base_url",
345        ),
346        other => ProviderError::new(
347            "openapi_upstream_error",
348            provider,
349            Some(action.to_owned()),
350            format!("OpenAPI upstream request failed: {other}"),
351            "Check the provider endpoint, input, and credentials, then retry.",
352        ),
353    }
354}
355
356#[cfg(test)]
357#[path = "openapi_tests.rs"]
358mod tests;