Skip to main content

object_store/
signer.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Abstraction of signed URL generation for those object store implementations that support it
19
20use crate::{Result, path::Path};
21use async_trait::async_trait;
22use std::{fmt, time::Duration};
23
24// publicly re-export types from http/url used in API so downstream consumers do
25// not have to explicitly add those crates as dependencies
26pub use http::Method;
27pub use http::{HeaderMap, HeaderName, HeaderValue};
28pub use url::Url;
29
30/// Additional parameters to fold into a presigned URL's signature, used with
31/// [`Signer::signed_url_opts`].
32///
33/// All values are fixed at signing time: the recipient of the presigned URL must send exactly
34/// these query parameters and headers, with these values, for the request to be accepted.
35///
36/// Construct with [`SignedUrlOptions::default`] (or the builder methods) and set only the fields
37/// you need, so that future additions remain backwards compatible:
38///
39/// ```
40/// # use object_store::signer::SignedUrlOptions;
41/// # use http::header::{CONTENT_TYPE, HeaderValue};
42/// let options = SignedUrlOptions::default()
43///     .with_query([("partNumber", "1"), ("uploadId", "abc123")])
44///     .with_signed_header(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
45/// ```
46#[derive(Debug, Clone, Default)]
47#[non_exhaustive]
48pub struct SignedUrlOptions {
49    /// Query parameters to sign and append to the URL.
50    ///
51    /// Used for requests that carry signed query parameters — for example a multipart
52    /// `UploadPart` (`partNumber`, `uploadId`) or pinning a `versionId`.
53    pub extra_query: Vec<(String, String)>,
54    /// Request headers to bind to the signature.
55    ///
56    /// Used to require the recipient to send specific headers, such as a checksum
57    /// (`x-amz-checksum-sha256`), `content-type`, or server-side-encryption headers.
58    pub signed_headers: HeaderMap,
59}
60
61impl SignedUrlOptions {
62    /// Create an empty set of options, equivalent to [`SignedUrlOptions::default`].
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Append query parameters to sign.
68    pub fn with_query<I, K, V>(mut self, query: I) -> Self
69    where
70        I: IntoIterator<Item = (K, V)>,
71        K: Into<String>,
72        V: Into<String>,
73    {
74        self.extra_query
75            .extend(query.into_iter().map(|(k, v)| (k.into(), v.into())));
76        self
77    }
78
79    /// Bind a request header to the signature.
80    pub fn with_signed_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
81        self.signed_headers.append(name, value);
82        self
83    }
84
85    /// Returns `true` if no extra query parameters or signed headers have been set.
86    pub fn is_empty(&self) -> bool {
87        self.extra_query.is_empty() && self.signed_headers.is_empty()
88    }
89}
90
91/// Universal API to generate presigned URLs from multiple object store services.
92#[async_trait]
93pub trait Signer: Send + Sync + fmt::Debug + 'static {
94    /// Given the intended [`Method`] and [`Path`] to use and the desired length of time for which
95    /// the URL should be valid, return a signed [`Url`] created with the object store
96    /// implementation's credentials such that the URL can be handed to something that doesn't have
97    /// access to the object store's credentials, to allow limited access to the object store.
98    async fn signed_url(&self, method: Method, path: &Path, expires_in: Duration) -> Result<Url>;
99
100    /// Like [`Signer::signed_url`], but additionally folds the query parameters and headers in
101    /// `options` into the signature. See [`SignedUrlOptions`].
102    ///
103    /// This presigns a *single* request (for example one multipart `UploadPart`). Orchestrating a
104    /// multipart upload — creating and completing it — is not in scope and is typically done by
105    /// the credential-holding server via [`MultipartStore`](crate::multipart::MultipartStore).
106    ///
107    /// The default implementation delegates to [`Signer::signed_url`] when `options` is empty, and
108    /// otherwise returns [`crate::Error::NotSupported`]: implementations that do not support
109    /// signing additional query parameters or headers must not silently drop them, as that would
110    /// produce a URL that does not enforce the requested constraints.
111    ///
112    /// There is intentionally no `signed_urls_opts` batch counterpart: signed parameters such as
113    /// `partNumber` are per-request, so a batch sharing one set of options across many paths has
114    /// no clear meaning.
115    async fn signed_url_opts(
116        &self,
117        method: Method,
118        path: &Path,
119        expires_in: Duration,
120        options: &SignedUrlOptions,
121    ) -> Result<Url> {
122        if options.is_empty() {
123            return self.signed_url(method, path, expires_in).await;
124        }
125        Err(crate::Error::NotSupported {
126            source: "this object store does not support signing URLs with additional \
127                     query parameters or headers"
128                .into(),
129        })
130    }
131
132    /// Generate signed urls for multiple paths.
133    ///
134    /// See [`Signer::signed_url`] for more details.
135    async fn signed_urls(
136        &self,
137        method: Method,
138        paths: &[Path],
139        expires_in: Duration,
140    ) -> Result<Vec<Url>> {
141        let mut urls = Vec::with_capacity(paths.len());
142        for path in paths {
143            urls.push(self.signed_url(method.clone(), path, expires_in).await?);
144        }
145        Ok(urls)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::Error;
153    use http::header::CONTENT_TYPE;
154
155    /// A [`Signer`] that only implements the required `signed_url`, relying on the default
156    /// `signed_url_opts` — mirroring providers, such as Azure, that only implement `signed_url`.
157    #[derive(Debug)]
158    struct MinimalSigner;
159
160    #[async_trait]
161    impl Signer for MinimalSigner {
162        async fn signed_url(
163            &self,
164            _method: Method,
165            path: &Path,
166            _expires_in: Duration,
167        ) -> Result<Url> {
168            Ok(Url::parse(&format!("https://example.com/{path}")).unwrap())
169        }
170    }
171
172    #[tokio::test]
173    async fn default_signed_url_opts_delegates_when_empty() {
174        let signer = MinimalSigner;
175        let url = signer
176            .signed_url_opts(
177                Method::GET,
178                &Path::from("file.txt"),
179                Duration::from_secs(60),
180                &SignedUrlOptions::default(),
181            )
182            .await
183            .unwrap();
184        assert_eq!(url.as_str(), "https://example.com/file.txt");
185    }
186
187    #[tokio::test]
188    async fn default_signed_url_opts_rejects_extras() {
189        let signer = MinimalSigner;
190
191        let query_err = signer
192            .signed_url_opts(
193                Method::PUT,
194                &Path::from("file.txt"),
195                Duration::from_secs(60),
196                &SignedUrlOptions::default().with_query([("partNumber", "1")]),
197            )
198            .await
199            .unwrap_err();
200        assert!(matches!(query_err, Error::NotSupported { .. }));
201
202        let header_err = signer
203            .signed_url_opts(
204                Method::PUT,
205                &Path::from("file.txt"),
206                Duration::from_secs(60),
207                &SignedUrlOptions::default()
208                    .with_signed_header(CONTENT_TYPE, HeaderValue::from_static("text/plain")),
209            )
210            .await
211            .unwrap_err();
212        assert!(matches!(header_err, Error::NotSupported { .. }));
213    }
214}