Skip to main content

minco_plugin_object_storage/
http.rs

1//! Authenticated HTTP control plane for direct object transfers.
2#![forbid(unsafe_code)]
3
4use crate::{
5    MultipartPartGrant, MultipartPartReceipt, MultipartUploadGrant, ObjectByteRange,
6    ObjectDownloadGrant, ObjectKey, ObjectValidationState, PresignedObjectRequest,
7};
8use async_trait::async_trait;
9use axum::{
10    Extension, Json, Router,
11    extract::{DefaultBodyLimit, Path, State},
12    response::{IntoResponse, Response},
13    routing::{delete, get, post},
14};
15use http::{HeaderMap, StatusCode, header};
16use minco_core::{OperationDescriptor, PluginId};
17use minco_http::{
18    ApiFailure, ApiResponseMetadata, BearerChallenge, HttpModule, Principal, REQUEST_ID_HEADER,
19    StrongEntityTag, parse_if_match,
20};
21use serde::{Deserialize, Serialize};
22use std::{collections::BTreeMap, fmt, sync::Arc};
23use uuid::Uuid;
24
25pub const OBJECT_TRANSFER_BASE_PATH: &str = "/_minco/objects";
26/// Bounded for the provider's maximum 10,000-part completion manifest while
27/// remaining below API Gateway's and synchronous Lambda's payload ceilings.
28pub const OBJECT_TRANSFER_HTTP_BODY_BYTES: usize = 3 * 1024 * 1024;
29pub const MAX_MULTIPART_ENTITY_TAG_BYTES: usize = 64;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ObjectTransferRequestContext {
33    pub request_id: String,
34    pub principal: Principal,
35    pub idempotency_key: Option<String>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct InitiateTransferUpload {
41    /// Application-defined closed purpose/profile name. It never selects a
42    /// provider, bucket, credentials, or arbitrary object prefix.
43    pub purpose: String,
44    pub content_type: String,
45    pub size_bytes: u64,
46    pub sha256: Option<String>,
47    pub file_name: Option<String>,
48    /// Logical application object being replaced. The HTTP handler binds the
49    /// optional `If-Match` value separately before invoking the use case.
50    pub replaces_object_id: Option<String>,
51    #[serde(skip)]
52    pub if_match: Option<String>,
53    #[serde(default)]
54    pub attributes: BTreeMap<String, String>,
55}
56
57#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct SingleTransferUploadGrant {
59    pub upload_id: Uuid,
60    pub key: ObjectKey,
61    pub request: PresignedObjectRequest,
62}
63
64impl fmt::Debug for SingleTransferUploadGrant {
65    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
66        formatter
67            .debug_struct("SingleTransferUploadGrant")
68            .field("upload_id", &self.upload_id)
69            .field("key", &self.key)
70            .field("request", &self.request)
71            .finish()
72    }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(tag = "mode", rename_all = "snake_case")]
77pub enum TransferUploadGrant {
78    Single(SingleTransferUploadGrant),
79    Multipart(MultipartUploadGrant),
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct TransferUploadResponse {
84    pub upload: TransferUploadGrant,
85    pub validation: ObjectValidationState,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct IssueTransferPart {
91    pub sha256: String,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct CompleteTransferUpload {
97    #[serde(default)]
98    pub parts: Vec<MultipartPartReceipt>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct CompletedTransferUpload {
103    /// Stable application identifier, never a provider key used as
104    /// authorization.
105    pub object_id: String,
106    pub revision: String,
107    pub entity_tag: String,
108    pub validation: ObjectValidationState,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct IssueTransferDownload {
114    pub object_id: String,
115    pub range: Option<ObjectByteRange>,
116    pub expected_entity_tag: Option<String>,
117    pub download_file_name: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct ObjectTransferMetadata {
122    pub object_id: String,
123    pub revision: String,
124    pub content_type: String,
125    pub size_bytes: u64,
126    /// Strong validator for this authorized metadata representation. The
127    /// application must change it when the resolved object revision,
128    /// validation state, or other download-eligibility state changes. It is
129    /// distinct from the provider byte validator in a download grant.
130    pub entity_tag: String,
131    pub last_modified: chrono::DateTime<chrono::Utc>,
132    pub validation: ObjectValidationState,
133}
134
135/// Application-owned authorization and durable transfer-session boundary.
136/// Each HTTP handler invokes exactly one method on this port.
137#[async_trait]
138pub trait ObjectTransferHttpUseCases: Send + Sync + fmt::Debug {
139    async fn initiate_upload(
140        &self,
141        context: ObjectTransferRequestContext,
142        request: InitiateTransferUpload,
143    ) -> Result<TransferUploadResponse, ObjectTransferApiError>;
144
145    async fn issue_part(
146        &self,
147        context: ObjectTransferRequestContext,
148        upload_id: Uuid,
149        part_number: u32,
150        request: IssueTransferPart,
151    ) -> Result<MultipartPartGrant, ObjectTransferApiError>;
152
153    async fn complete_upload(
154        &self,
155        context: ObjectTransferRequestContext,
156        upload_id: Uuid,
157        request: CompleteTransferUpload,
158    ) -> Result<CompletedTransferUpload, ObjectTransferApiError>;
159
160    async fn abort_upload(
161        &self,
162        context: ObjectTransferRequestContext,
163        upload_id: Uuid,
164    ) -> Result<(), ObjectTransferApiError>;
165
166    async fn issue_download(
167        &self,
168        context: ObjectTransferRequestContext,
169        request: IssueTransferDownload,
170    ) -> Result<ObjectDownloadGrant, ObjectTransferApiError>;
171
172    async fn get_metadata(
173        &self,
174        context: ObjectTransferRequestContext,
175        object_id: String,
176    ) -> Result<ObjectTransferMetadata, ObjectTransferApiError>;
177}
178
179#[derive(Clone)]
180pub struct ObjectTransferHttpService(Arc<dyn ObjectTransferHttpUseCases>);
181
182impl ObjectTransferHttpService {
183    pub fn new(use_cases: Arc<dyn ObjectTransferHttpUseCases>) -> Self {
184        Self(use_cases)
185    }
186}
187
188impl fmt::Debug for ObjectTransferHttpService {
189    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190        formatter.debug_tuple("ObjectTransferHttpService").finish()
191    }
192}
193
194pub fn object_transfer_operations() -> Vec<OperationDescriptor> {
195    [
196        (
197            "initiateObjectUpload",
198            "POST",
199            "/_minco/objects/uploads",
200            false,
201        ),
202        (
203            "issueObjectUploadPart",
204            "POST",
205            "/_minco/objects/uploads/{uploadId}/parts/{partNumber}",
206            true,
207        ),
208        (
209            "completeObjectUpload",
210            "POST",
211            "/_minco/objects/uploads/{uploadId}/complete",
212            true,
213        ),
214        (
215            "abortObjectUpload",
216            "DELETE",
217            "/_minco/objects/uploads/{uploadId}",
218            true,
219        ),
220        (
221            "issueObjectDownload",
222            "POST",
223            "/_minco/objects/downloads",
224            true,
225        ),
226        (
227            "getObjectTransferMetadata",
228            "GET",
229            "/_minco/objects/{objectId}",
230            true,
231        ),
232    ]
233    .into_iter()
234    .map(
235        |(operation_id, method, path, idempotent)| OperationDescriptor {
236            operation_id: operation_id.into(),
237            method: method.into(),
238            path: path.into(),
239            public: false,
240            idempotent,
241        },
242    )
243    .collect()
244}
245
246pub fn object_transfer_http_module(
247    plugin_id: PluginId,
248    service: ObjectTransferHttpService,
249) -> HttpModule {
250    HttpModule::new(plugin_id, object_transfer_router(service))
251        .with_operations(
252            object_transfer_operations()
253                .into_iter()
254                .map(|operation| operation.operation_id),
255        )
256        .with_max_request_body_bytes(OBJECT_TRANSFER_HTTP_BODY_BYTES)
257}
258
259pub fn object_transfer_router(service: ObjectTransferHttpService) -> Router {
260    let routes = Router::new()
261        .route("/uploads", post(initiate_upload))
262        .route("/uploads/{upload_id}/parts/{part_number}", post(issue_part))
263        .route("/uploads/{upload_id}/complete", post(complete_upload))
264        .route("/uploads/{upload_id}", delete(abort_upload))
265        .route("/downloads", post(issue_download))
266        .route("/{object_id}", get(get_metadata))
267        .layer(DefaultBodyLimit::max(OBJECT_TRANSFER_HTTP_BODY_BYTES))
268        .with_state(service);
269    Router::new().nest(OBJECT_TRANSFER_BASE_PATH, routes)
270}
271
272async fn initiate_upload(
273    State(service): State<ObjectTransferHttpService>,
274    principal: Option<Extension<Principal>>,
275    headers: HeaderMap,
276    Json(mut request): Json<InitiateTransferUpload>,
277) -> Result<Response, Response> {
278    let context = context(principal, &headers)?;
279    validate_initiate(&request, &context.request_id)?;
280    request.if_match = if headers.contains_key(header::IF_MATCH) {
281        let tag = parse_if_match(&headers)
282            .map_err(|_| ApiFailure::invalid_if_match(&context.request_id).into_response())?;
283        Some(
284            tag.to_header_value()
285                .to_str()
286                .expect("validated entity tag is ASCII")
287                .to_owned(),
288        )
289    } else {
290        None
291    };
292    if request.replaces_object_id.is_some() && request.if_match.is_none() {
293        return Err(ApiFailure::precondition_required(context.request_id).into_response());
294    }
295    let response = service
296        .0
297        .initiate_upload(context.clone(), request)
298        .await
299        .map_err(|error| api_error(error, context.request_id))?;
300    Ok((StatusCode::CREATED, Json(response)).into_response())
301}
302
303async fn issue_part(
304    State(service): State<ObjectTransferHttpService>,
305    principal: Option<Extension<Principal>>,
306    headers: HeaderMap,
307    Path((upload_id, part_number)): Path<(String, String)>,
308    Json(request): Json<IssueTransferPart>,
309) -> Result<Response, Response> {
310    let context = context(principal, &headers)?;
311    let upload_id = parse_uuid(&upload_id, "upload_id", &context.request_id)?;
312    let part_number = part_number.parse::<u32>().map_err(|_| {
313        ApiFailure::validation("part_number must be an integer", &context.request_id)
314            .into_response()
315    })?;
316    if !(1..=crate::MAX_MULTIPART_PARTS).contains(&part_number) || !valid_sha256(&request.sha256) {
317        return Err(ApiFailure::validation(
318            "part_number or SHA-256 is invalid",
319            &context.request_id,
320        )
321        .into_response());
322    }
323    let response = service
324        .0
325        .issue_part(context.clone(), upload_id, part_number, request)
326        .await
327        .map_err(|error| api_error(error, context.request_id))?;
328    Ok(Json(response).into_response())
329}
330
331async fn complete_upload(
332    State(service): State<ObjectTransferHttpService>,
333    principal: Option<Extension<Principal>>,
334    headers: HeaderMap,
335    Path(upload_id): Path<String>,
336    Json(request): Json<CompleteTransferUpload>,
337) -> Result<Response, Response> {
338    let context = context(principal, &headers)?;
339    let upload_id = parse_uuid(&upload_id, "upload_id", &context.request_id)?;
340    if request.parts.len() > crate::MAX_MULTIPART_PARTS as usize
341        || request.parts.iter().any(|part| {
342            !(1..=crate::MAX_MULTIPART_PARTS).contains(&part.part_number)
343                || !valid_sha256(&part.sha256)
344                || part.entity_tag.is_empty()
345                || part.entity_tag.len() > MAX_MULTIPART_ENTITY_TAG_BYTES
346                || part.entity_tag.chars().any(char::is_control)
347        })
348    {
349        return Err(ApiFailure::validation(
350            "multipart completion manifest is invalid",
351            &context.request_id,
352        )
353        .into_response());
354    }
355    let response = service
356        .0
357        .complete_upload(context.clone(), upload_id, request)
358        .await
359        .map_err(|error| api_error(error, context.request_id))?;
360    Ok(Json(response).into_response())
361}
362
363async fn abort_upload(
364    State(service): State<ObjectTransferHttpService>,
365    principal: Option<Extension<Principal>>,
366    headers: HeaderMap,
367    Path(upload_id): Path<String>,
368) -> Result<Response, Response> {
369    let context = context(principal, &headers)?;
370    let upload_id = parse_uuid(&upload_id, "upload_id", &context.request_id)?;
371    service
372        .0
373        .abort_upload(context.clone(), upload_id)
374        .await
375        .map_err(|error| api_error(error, context.request_id))?;
376    Ok(StatusCode::NO_CONTENT.into_response())
377}
378
379async fn issue_download(
380    State(service): State<ObjectTransferHttpService>,
381    principal: Option<Extension<Principal>>,
382    headers: HeaderMap,
383    Json(request): Json<IssueTransferDownload>,
384) -> Result<Response, Response> {
385    let context = context(principal, &headers)?;
386    validate_download(&request, &context.request_id)?;
387    let response = service
388        .0
389        .issue_download(context.clone(), request)
390        .await
391        .map_err(|error| api_error(error, context.request_id))?;
392    Ok(Json(response).into_response())
393}
394
395async fn get_metadata(
396    State(service): State<ObjectTransferHttpService>,
397    principal: Option<Extension<Principal>>,
398    headers: HeaderMap,
399    Path(object_id): Path<String>,
400) -> Result<Response, Response> {
401    let context = context(principal, &headers)?;
402    if !valid_bounded_text(&object_id, 256) {
403        return Err(
404            ApiFailure::validation("object_id is invalid", context.request_id).into_response(),
405        );
406    }
407    let metadata = service
408        .0
409        .get_metadata(context.clone(), object_id)
410        .await
411        .map_err(|error| api_error(error, context.request_id.clone()))?;
412    let entity_tag = parse_application_entity_tag(&metadata.entity_tag)
413        .map_err(|()| ApiFailure::internal(context.request_id.clone()).into_response())?;
414    let not_modified = if_none_match_matches(&headers, entity_tag.opaque());
415    let mut response = if not_modified {
416        StatusCode::NOT_MODIFIED.into_response()
417    } else {
418        Json(metadata).into_response()
419    };
420    response
421        .headers_mut()
422        .insert(header::ETAG, entity_tag.to_header_value());
423    response.headers_mut().insert(
424        header::CACHE_CONTROL,
425        http::HeaderValue::from_static("private, no-cache"),
426    );
427    response.headers_mut().insert(
428        header::VARY,
429        http::HeaderValue::from_static("Authorization"),
430    );
431    Ok(response)
432}
433
434#[allow(clippy::result_large_err)]
435fn context(
436    principal: Option<Extension<Principal>>,
437    headers: &HeaderMap,
438) -> Result<ObjectTransferRequestContext, Response> {
439    let request_id = request_id(headers);
440    let Some(Extension(principal)) = principal else {
441        return Err(ApiResponseMetadata::new()
442            .bearer_challenge(BearerChallenge::Required)
443            .wrap(ApiFailure::new(
444                StatusCode::UNAUTHORIZED,
445                "authentication_required",
446                "Authentication required",
447                "A valid authenticated principal is required.",
448                request_id,
449            ))
450            .into_response());
451    };
452    let idempotency_key = parse_idempotency_key(headers, &request_id)?;
453    Ok(ObjectTransferRequestContext {
454        request_id,
455        principal,
456        idempotency_key,
457    })
458}
459
460fn request_id(headers: &HeaderMap) -> String {
461    headers
462        .get(&REQUEST_ID_HEADER)
463        .and_then(|value| value.to_str().ok())
464        .filter(|value| valid_bounded_text(value, 200))
465        .map_or_else(|| Uuid::now_v7().to_string(), str::to_owned)
466}
467
468#[allow(clippy::result_large_err)]
469fn parse_uuid(value: &str, field: &str, request_id: &str) -> Result<Uuid, Response> {
470    Uuid::parse_str(value).map_err(|_| {
471        ApiFailure::validation(format!("{field} must be a UUID"), request_id).into_response()
472    })
473}
474
475#[allow(clippy::result_large_err)]
476fn parse_idempotency_key(
477    headers: &HeaderMap,
478    request_id: &str,
479) -> Result<Option<String>, Response> {
480    let mut values = headers.get_all("idempotency-key").iter();
481    let Some(value) = values.next() else {
482        return Ok(None);
483    };
484    if values.next().is_some() {
485        return Err(
486            ApiFailure::validation("Idempotency-Key is invalid", request_id).into_response(),
487        );
488    }
489    let value = value.to_str().map_err(|_| {
490        ApiFailure::validation("Idempotency-Key is invalid", request_id).into_response()
491    })?;
492    if !valid_bounded_text(value, 200) {
493        return Err(
494            ApiFailure::validation("Idempotency-Key is invalid", request_id).into_response(),
495        );
496    }
497    Ok(Some(value.to_owned()))
498}
499
500#[allow(clippy::result_large_err)]
501fn validate_initiate(request: &InitiateTransferUpload, request_id: &str) -> Result<(), Response> {
502    let valid_purpose = !request.purpose.is_empty()
503        && request.purpose.len() <= 128
504        && request.purpose.bytes().all(|byte| {
505            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
506        });
507    let valid_type = request
508        .content_type
509        .split_once('/')
510        .is_some_and(|(top, subtype)| {
511            !top.is_empty()
512                && !subtype.is_empty()
513                && !subtype.contains('/')
514                && request.content_type.len() <= 255
515                && request.content_type.bytes().all(|byte| {
516                    byte.is_ascii_alphanumeric()
517                        || matches!(
518                            byte,
519                            b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' | b'/'
520                        )
521                })
522        });
523    let valid_checksum = request.sha256.as_deref().is_none_or(valid_sha256);
524    let valid_file_name = request
525        .file_name
526        .as_deref()
527        .is_none_or(|value| valid_bounded_text(value, 255));
528    let valid_replacement = request
529        .replaces_object_id
530        .as_deref()
531        .is_none_or(|value| valid_bounded_text(value, 256));
532    let valid_attributes = request.attributes.len() <= 31
533        && request.attributes.iter().all(|(key, value)| {
534            valid_bounded_text(key, 128)
535                && !key.starts_with("minco.")
536                && value.len() <= 1_024
537                && !value.chars().any(char::is_control)
538        });
539    if valid_purpose
540        && valid_type
541        && request.size_bytes > 0
542        && valid_checksum
543        && valid_file_name
544        && valid_replacement
545        && valid_attributes
546    {
547        Ok(())
548    } else {
549        Err(
550            ApiFailure::validation("object upload declaration is invalid", request_id)
551                .into_response(),
552        )
553    }
554}
555
556#[allow(clippy::result_large_err)]
557fn validate_download(request: &IssueTransferDownload, request_id: &str) -> Result<(), Response> {
558    let valid = valid_bounded_text(&request.object_id, 256)
559        && request.range.is_none_or(|range| range.validate().is_ok())
560        && request
561            .expected_entity_tag
562            .as_deref()
563            .is_none_or(|value| valid_bounded_text(value, 256) && !value.starts_with("W/"))
564        && request
565            .download_file_name
566            .as_deref()
567            .is_none_or(|value| valid_bounded_text(value, 255));
568    if valid {
569        Ok(())
570    } else {
571        Err(
572            ApiFailure::validation("object download declaration is invalid", request_id)
573                .into_response(),
574        )
575    }
576}
577
578fn valid_sha256(value: &str) -> bool {
579    value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
580}
581
582fn valid_bounded_text(value: &str, maximum: usize) -> bool {
583    !value.is_empty() && value.len() <= maximum && !value.chars().any(char::is_control)
584}
585
586fn parse_application_entity_tag(value: &str) -> Result<StrongEntityTag, ()> {
587    let opaque = value
588        .strip_prefix('"')
589        .and_then(|value| value.strip_suffix('"'))
590        .ok_or(())?;
591    StrongEntityTag::from_opaque(opaque).map_err(|_| ())
592}
593
594fn if_none_match_matches(headers: &HeaderMap, current_opaque: &str) -> bool {
595    headers.get_all(header::IF_NONE_MATCH).iter().any(|value| {
596        value.to_str().is_ok_and(|value| {
597            value.split(',').any(|candidate| {
598                let candidate = candidate.trim();
599                if candidate == "*" {
600                    return true;
601                }
602                let candidate = candidate.strip_prefix("W/").unwrap_or(candidate);
603                candidate
604                    .strip_prefix('"')
605                    .and_then(|candidate| candidate.strip_suffix('"'))
606                    .and_then(|opaque| StrongEntityTag::from_opaque(opaque).ok())
607                    .is_some_and(|candidate| candidate.opaque() == current_opaque)
608            })
609        })
610    })
611}
612
613fn api_error(error: ObjectTransferApiError, request_id: String) -> Response {
614    let (status, code, title, detail) = match error {
615        ObjectTransferApiError::Forbidden => (
616            StatusCode::FORBIDDEN,
617            "object_transfer_forbidden",
618            "Object transfer forbidden",
619            "The principal is not authorized for this object transfer.",
620        ),
621        ObjectTransferApiError::NotFound => (
622            StatusCode::NOT_FOUND,
623            "object_transfer_not_found",
624            "Object transfer not found",
625            "The requested object or transfer session does not exist.",
626        ),
627        ObjectTransferApiError::Conflict => (
628            StatusCode::CONFLICT,
629            "object_transfer_conflict",
630            "Object transfer conflict",
631            "The transfer is not in a state that accepts this operation.",
632        ),
633        ObjectTransferApiError::PreconditionFailed => (
634            StatusCode::PRECONDITION_FAILED,
635            "precondition_failed",
636            "Precondition failed",
637            "The object changed after it was read. Fetch the current revision and retry.",
638        ),
639        ObjectTransferApiError::Validation(detail) => {
640            return ApiFailure::validation(detail, request_id).into_response();
641        }
642        ObjectTransferApiError::Expired => (
643            StatusCode::GONE,
644            "object_transfer_expired",
645            "Object transfer expired",
646            "The transfer session expired; start a new session.",
647        ),
648        ObjectTransferApiError::Unavailable => (
649            StatusCode::SERVICE_UNAVAILABLE,
650            "object_transfer_unavailable",
651            "Object transfer unavailable",
652            "The object provider is temporarily unavailable.",
653        ),
654        ObjectTransferApiError::Internal => (
655            StatusCode::INTERNAL_SERVER_ERROR,
656            "internal_error",
657            "Internal server error",
658            "The request could not be completed.",
659        ),
660    };
661    ApiFailure::new(status, code, title, detail, request_id).into_response()
662}
663
664#[non_exhaustive]
665#[derive(Debug, thiserror::Error)]
666pub enum ObjectTransferApiError {
667    #[error("object transfer is forbidden")]
668    Forbidden,
669    #[error("object or transfer session was not found")]
670    NotFound,
671    #[error("object transfer state conflicts with the request")]
672    Conflict,
673    #[error("object transfer precondition failed")]
674    PreconditionFailed,
675    #[error("object transfer validation failed: {0}")]
676    Validation(String),
677    #[error("object transfer session expired")]
678    Expired,
679    #[error("object provider is unavailable")]
680    Unavailable,
681    #[error("object transfer failed internally")]
682    Internal,
683}