use async_trait::async_trait;
use reqwest::{Client as ReqwestClient, StatusCode};
use serde::Deserialize;
use url::Url;
use uuid::Uuid;
use crate::Config;
use crate::api::tables::{
AwsSession, DirectRead, GcsAccessToken, SecretToken, SignedAccess, StorageAccess,
StorageBinding as IssuedBinding, StorageMode, VendedAccess, VendedCredential,
};
use crate::auth::TokenSource;
use crate::scandal::error::ScandalError;
use crate::scandal::{ResolvedUri, ResourceAccess, StorageBinding, UriResolver};
#[derive(Clone)]
pub struct ResourceUriResolver {
api_url: Url,
auth: TokenSource,
http: ReqwestClient,
}
impl ResourceUriResolver {
pub fn from_config(config: &Config) -> Self {
Self::new(
config.api_url.clone(),
config.auth.clone(),
config.http.clone(),
)
}
pub fn new(api_url: Url, auth: TokenSource, http: ReqwestClient) -> Self {
Self {
api_url,
auth,
http,
}
}
}
#[async_trait]
impl UriResolver for ResourceUriResolver {
async fn resolve(&self, uri: &Url) -> Result<ResolvedUri, ScandalError> {
self.resolve_for(uri, ResourceAccess::Read).await
}
async fn resolve_for(
&self,
uri: &Url,
access: ResourceAccess,
) -> Result<ResolvedUri, ScandalError> {
if uri.scheme() != "spiral"
|| uri.query().is_some()
|| uri.fragment().is_some()
|| !uri.username().is_empty()
|| uri.password().is_some()
|| uri.port().is_some()
{
return Err(ScandalError::InvalidArgument {
message: "this host resolves only spiral:// resource addresses".into(),
});
}
let resource = uri
.host_str()
.and_then(|host| Uuid::parse_str(host).ok())
.ok_or_else(|| ScandalError::InvalidArgument {
message: "Spiral address requires a resource UUID".into(),
})?;
let token = self.auth.token().await.map_err(internal)?.ok_or_else(|| {
ScandalError::Unauthenticated {
message: "URI resolution requires the host's credentials".into(),
}
})?;
let response = self
.http
.post(format!(
"{}/v1/resources/{resource}/storage-binding",
self.api_url.as_str().trim_end_matches('/')
))
.bearer_auth(token.expose_secret())
.json(&match access {
ResourceAccess::Read => serde_json::json!({"mode":"readOnly"}),
ResourceAccess::Attempt { attempt_id } => serde_json::json!({"mode":"readWrite", "write":{"type":"attempt","attemptId":attempt_id}}),
ResourceAccess::Publication => serde_json::json!({"mode":"readWrite", "write":{"type":"publication"}}),
})
.send()
.await
.map_err(unavailable)?;
match response.status() {
status if status.is_success() => {}
StatusCode::BAD_REQUEST => {
let refusal: BindingRefusal = bounded_json(response).await?;
return Err(ScandalError::InvalidArgument {
message: refusal.message,
});
}
StatusCode::UNAUTHORIZED => {
return Err(ScandalError::Unauthenticated {
message: "storage refused the host's credential".into(),
});
}
StatusCode::FORBIDDEN => {
return Err(ScandalError::PermissionDenied {
operation: "read".into(),
resource: resource.to_string(),
});
}
StatusCode::NOT_FOUND => {
return Err(ScandalError::NotFound {
resource: resource.to_string(),
});
}
status => return Err(unavailable(format!("storage returned {status}"))),
}
let binding: ResourceBinding = bounded_json(response).await?;
if (access == ResourceAccess::Read && binding.mode != "readOnly")
|| (access != ResourceAccess::Read && binding.mode != "readWrite")
{
return Err(internal("storage returned the wrong resource access mode"));
}
Ok(ResolvedUri {
root_uri: uri.join("/").map_err(internal)?,
storage: binding.into_storage()?,
})
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResourceBinding {
root_uri: String,
mode: String,
expires_at: u64,
access: ResourceStorageAccess,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum ResourceStorageAccess {
#[serde(rename_all = "camelCase")]
Signed {
endpoint: String,
token: SecretToken,
#[serde(default)]
direct_read: Option<ResourceDirectRead>,
},
Vended {
credential: ResourceCredential,
},
Unauthenticated,
}
#[derive(Deserialize)]
struct ResourceDirectRead {
uri: String,
credential: ResourceCredential,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum ResourceCredential {
AwsSession(AwsSession),
GcsAccessToken(GcsAccessToken),
}
impl From<ResourceCredential> for VendedCredential {
fn from(credential: ResourceCredential) -> Self {
match credential {
ResourceCredential::AwsSession(session) => Self::AwsSession(session),
ResourceCredential::GcsAccessToken(token) => Self::GcsAccessToken(token),
}
}
}
impl ResourceBinding {
fn into_storage(self) -> Result<StorageBinding, ScandalError> {
let mode = if self.mode == "readOnly" {
StorageMode::ReadOnly
} else {
StorageMode::ReadWrite
};
let access = match self.access {
ResourceStorageAccess::Unauthenticated => {
return Ok(StorageBinding::new(self.root_uri));
}
ResourceStorageAccess::Signed {
endpoint,
token,
direct_read,
} => StorageAccess::Signed(SignedAccess {
endpoint,
token,
direct_read: direct_read.map(|direct| DirectRead {
uri: direct.uri,
credential: direct.credential.into(),
}),
}),
ResourceStorageAccess::Vended { credential } => StorageAccess::Vended(VendedAccess {
credential: credential.into(),
}),
};
StorageBinding::for_resource(IssuedBinding {
root_uri: self.root_uri,
mode,
expires_at: self.expires_at.into(),
access,
})
}
}
#[derive(Deserialize)]
struct BindingRefusal {
message: String,
}
async fn bounded_json<T: for<'de> Deserialize<'de>>(
response: reqwest::Response,
) -> Result<T, ScandalError> {
let mut response = response;
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(unavailable)? {
if bytes.len() + chunk.len() > 1024 * 1024 {
return Err(internal("oversized host credential response"));
}
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes).map_err(internal)
}
fn internal(error: impl ToString) -> ScandalError {
ScandalError::Internal {
message: error.to_string(),
}
}
fn unavailable(error: impl ToString) -> ScandalError {
ScandalError::Unavailable {
message: error.to_string(),
}
}