1use 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#[derive(Clone)]
21pub struct ResourceUriResolver {
22 api_url: Url,
23 auth: TokenSource,
24 http: ReqwestClient,
25}
26
27impl ResourceUriResolver {
28 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 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#[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}