Skip to main content

spiral/
uri_resolver.rs

1//! URI resolution owned by an authenticated SDK host.
2use async_trait::async_trait;
3use reqwest::StatusCode;
4use reqwest_middleware::ClientWithMiddleware as ReqwestClient;
5use serde::Deserialize;
6use url::Url;
7use uuid::Uuid;
8
9use crate::Config;
10use crate::api::tables::{
11    AwsSession, DirectRead, GcsAccessToken, SecretToken, SignedAccess, StorageAccess,
12    StorageBinding as IssuedBinding, StorageMode, VendedAccess, VendedCredential,
13};
14use crate::auth::TokenSource;
15use crate::scandal::error::ScandalError;
16use crate::scandal::{ResolvedUri, ResourceAccess, StorageBinding, UriResolver};
17
18/// Resolve Spiral resource addresses using the SDK host's shared credentials and HTTP pool.
19/// Provider URIs are not implicitly authorized; their policy requires a separate host resolver.
20#[derive(Clone)]
21pub struct ResourceUriResolver {
22    api_url: Url,
23    auth: TokenSource,
24    http: ReqwestClient,
25}
26
27impl ResourceUriResolver {
28    /// Use the same endpoint, token provider and HTTP pool as `config`'s resource clients.
29    pub fn from_config(config: &Config) -> Self {
30        Self::new(
31            config.api_url.clone(),
32            config.auth.clone(),
33            config.http.clone(),
34        )
35    }
36
37    /// Resolve as the identity `auth` provides, through the stack at `api_url`. `http` must
38    /// not follow redirects, since requests carry the identity's bearer token.
39    pub fn new(api_url: Url, auth: TokenSource, http: impl Into<ReqwestClient>) -> Self {
40        let http = http.into();
41        Self {
42            api_url,
43            auth,
44            http,
45        }
46    }
47}
48
49#[async_trait]
50impl UriResolver for ResourceUriResolver {
51    async fn resolve(&self, uri: &Url) -> Result<ResolvedUri, ScandalError> {
52        self.resolve_for(uri, ResourceAccess::Read).await
53    }
54
55    async fn resolve_for(
56        &self,
57        uri: &Url,
58        access: ResourceAccess,
59    ) -> Result<ResolvedUri, ScandalError> {
60        if uri.scheme() != "spiral"
61            || uri.query().is_some()
62            || uri.fragment().is_some()
63            || !uri.username().is_empty()
64            || uri.password().is_some()
65            || uri.port().is_some()
66        {
67            return Err(ScandalError::InvalidArgument {
68                message: "this host resolves only spiral:// resource addresses".into(),
69            });
70        }
71        let resource = uri
72            .host_str()
73            .and_then(|host| Uuid::parse_str(host).ok())
74            .ok_or_else(|| ScandalError::InvalidArgument {
75                message: "Spiral address requires a resource UUID".into(),
76            })?;
77        let token = self.auth.token().await.map_err(internal)?.ok_or_else(|| {
78            ScandalError::Unauthenticated {
79                message: "URI resolution requires the host's credentials".into(),
80            }
81        })?;
82        let response = self
83            .http
84            .post(format!(
85                "{}/v1/resources/{resource}/storage-binding",
86                self.api_url.as_str().trim_end_matches('/')
87            ))
88            .bearer_auth(token.expose_secret())
89            .json(&match access {
90                ResourceAccess::Read => serde_json::json!({"mode":"readOnly"}),
91                ResourceAccess::Attempt { attempt_id } => serde_json::json!({"mode":"readWrite", "write":{"type":"attempt","attemptId":attempt_id}}),
92                ResourceAccess::Publication => serde_json::json!({"mode":"readWrite", "write":{"type":"publication"}}),
93            })
94            .send()
95            .await
96            .map_err(unavailable)?;
97        match response.status() {
98            status if status.is_success() => {}
99            StatusCode::BAD_REQUEST => {
100                let refusal: BindingRefusal = bounded_json(response).await?;
101                return Err(ScandalError::InvalidArgument {
102                    message: refusal.message,
103                });
104            }
105            StatusCode::UNAUTHORIZED => {
106                return Err(ScandalError::Unauthenticated {
107                    message: "storage refused the host's credential".into(),
108                });
109            }
110            StatusCode::FORBIDDEN => {
111                return Err(ScandalError::PermissionDenied {
112                    operation: "read".into(),
113                    resource: resource.to_string(),
114                });
115            }
116            StatusCode::NOT_FOUND => {
117                return Err(ScandalError::NotFound {
118                    resource: resource.to_string(),
119                });
120            }
121            status => return Err(unavailable(format!("storage returned {status}"))),
122        }
123        let binding: ResourceBinding = bounded_json(response).await?;
124        if (access == ResourceAccess::Read && binding.mode != "readOnly")
125            || (access != ResourceAccess::Read && binding.mode != "readWrite")
126        {
127            return Err(internal("storage returned the wrong resource access mode"));
128        }
129        Ok(ResolvedUri {
130            root_uri: uri.join("/").map_err(internal)?,
131            storage: binding.into_storage()?,
132        })
133    }
134}
135
136// The resource binding route uses flat camelCase wire objects.
137#[derive(Deserialize)]
138#[serde(rename_all = "camelCase")]
139struct ResourceBinding {
140    root_uri: String,
141    mode: String,
142    expires_at: u64,
143    access: ResourceStorageAccess,
144}
145
146#[derive(Deserialize)]
147#[serde(tag = "type", rename_all = "camelCase")]
148enum ResourceStorageAccess {
149    #[serde(rename_all = "camelCase")]
150    Signed {
151        endpoint: String,
152        token: SecretToken,
153        #[serde(default)]
154        direct_read: Option<ResourceDirectRead>,
155    },
156    Vended {
157        credential: ResourceCredential,
158    },
159    Unauthenticated,
160}
161
162#[derive(Deserialize)]
163struct ResourceDirectRead {
164    uri: String,
165    credential: ResourceCredential,
166}
167
168#[derive(Deserialize)]
169#[serde(tag = "type", rename_all = "camelCase")]
170enum ResourceCredential {
171    AwsSession(AwsSession),
172    GcsAccessToken(GcsAccessToken),
173}
174
175impl From<ResourceCredential> for VendedCredential {
176    fn from(credential: ResourceCredential) -> Self {
177        match credential {
178            ResourceCredential::AwsSession(session) => Self::AwsSession(session),
179            ResourceCredential::GcsAccessToken(token) => Self::GcsAccessToken(token),
180        }
181    }
182}
183
184impl ResourceBinding {
185    fn into_storage(self) -> Result<StorageBinding, ScandalError> {
186        let mode = if self.mode == "readOnly" {
187            StorageMode::ReadOnly
188        } else {
189            StorageMode::ReadWrite
190        };
191        let access = match self.access {
192            ResourceStorageAccess::Unauthenticated => {
193                return Ok(StorageBinding::new(self.root_uri));
194            }
195            ResourceStorageAccess::Signed {
196                endpoint,
197                token,
198                direct_read,
199            } => StorageAccess::Signed(SignedAccess {
200                endpoint,
201                token,
202                direct_read: direct_read.map(|direct| DirectRead {
203                    uri: direct.uri,
204                    credential: direct.credential.into(),
205                }),
206            }),
207            ResourceStorageAccess::Vended { credential } => StorageAccess::Vended(VendedAccess {
208                credential: credential.into(),
209            }),
210        };
211        StorageBinding::for_resource(IssuedBinding {
212            root_uri: self.root_uri,
213            mode,
214            expires_at: self.expires_at.into(),
215            access,
216        })
217    }
218}
219
220#[derive(Deserialize)]
221struct BindingRefusal {
222    message: String,
223}
224
225async fn bounded_json<T: for<'de> Deserialize<'de>>(
226    response: reqwest::Response,
227) -> Result<T, ScandalError> {
228    let mut response = response;
229    let mut bytes = Vec::new();
230    while let Some(chunk) = response.chunk().await.map_err(unavailable)? {
231        if bytes.len() + chunk.len() > 1024 * 1024 {
232            return Err(internal("oversized host credential response"));
233        }
234        bytes.extend_from_slice(&chunk);
235    }
236    serde_json::from_slice(&bytes).map_err(internal)
237}
238
239fn internal(error: impl ToString) -> ScandalError {
240    ScandalError::Internal {
241        message: error.to_string(),
242    }
243}
244
245fn unavailable(error: impl ToString) -> ScandalError {
246    ScandalError::Unavailable {
247        message: error.to_string(),
248    }
249}