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}