1
1
//! [`VaultKeyStorage`] using S3-compatible storage.
2
//!
3
//! This is the recommended method for securing your BonsaiDb database. There
4
//! are many ways to acquire secure, inexpensive S3-compatible storage, such as
5
//! Backblaze B2.
6
//!
7
//! Do not configure your bucket with public access. You should only allow
8
//! access from the IP addresses that your BonsaiDb server(s) are hosted on,
9
//! or only allow authenticated access.
10
//!
11
//! To use this, specify the `vault_key_storage` configuration parameter:
12
//!
13
//! ```rust
14
//! # use bonsaidb_keystorage_s3::S3VaultKeyStorage;
15
//! # use aws_sdk_s3::Endpoint;
16
//! # use bonsaidb_core::{document::KeyId, test_util::TestDirectory};
17
//! # use bonsaidb_local::config::{StorageConfiguration, Builder};
18
//! # use http::Uri;
19
//! #
20
//! let directory = TestDirectory::new("bonsaidb-keystorage-s3-basic");
21
//! let configuration = StorageConfiguration::new(&directory)
22
//!     .vault_key_storage(
23
//!         S3VaultKeyStorage::new("bucket_name").endpoint(Endpoint::immutable(
24
//!             Uri::try_from("https://s3.us-west-001.backblazeb2.com").unwrap(),
25
//!         )),
26
//!     )
27
//!     .default_encryption_key(KeyId::Master);
28
//! ```
29
//!
30
//! The API calls are performed by the [`aws-sdk-s3`](aws_sdk_s3) crate.
31

            
32
#![forbid(unsafe_code)]
33
#![warn(
34
    clippy::cargo,
35
    missing_docs,
36
    // clippy::missing_docs_in_private_items,
37
    clippy::nursery,
38
    clippy::pedantic,
39
    future_incompatible,
40
    rust_2018_idioms,
41
)]
42
#![allow(
43
    clippy::missing_errors_doc, // TODO clippy::missing_errors_doc
44
    clippy::missing_panics_doc, // TODO clippy::missing_panics_doc
45
    clippy::option_if_let_else,
46
    clippy::module_name_repetitions,
47
)]
48

            
49
use std::fmt::Display;
50

            
51
use async_trait::async_trait;
52
use aws_config::meta::region::RegionProviderChain;
53
pub use aws_sdk_s3;
54
use aws_sdk_s3::{error::GetObjectErrorKind, Client, Endpoint, Region};
55
use bonsaidb_local::{
56
    vault::{KeyPair, VaultKeyStorage},
57
    StorageId,
58
};
59
pub use http;
60

            
61
/// S3-compatible [`VaultKeyStorage`] implementor.
62
3
#[derive(Debug, Default)]
63
pub struct S3VaultKeyStorage {
64
    bucket: String,
65
    /// The S3 endpoint to use. If not specified, the endpoint will be
66
    /// determined automatically. This field can be used to support non-AWS S3
67
    /// providers.
68
    pub endpoint: Option<Endpoint>,
69
    /// The AWS region to use. If not specified, the region will be determined
70
    /// by the aws sdk.
71
    pub region: Option<Region>,
72
    /// The path prefix for keys to be stored within.
73
    pub path: String,
74
}
75

            
76
impl S3VaultKeyStorage {
77
    /// Creates a new key storage instance for `bucket`.
78
    pub fn new(bucket: impl Display) -> Self {
79
        Self {
80
            bucket: bucket.to_string(),
81
            ..Self::default()
82
        }
83
    }
84

            
85
    /// Sets the path prefix for vault keys to be stored within.
86
1
    pub fn path(mut self, prefix: impl Display) -> Self {
87
1
        self.path = prefix.to_string();
88
1
        self
89
1
    }
90

            
91
    /// Sets the endpoint to use. See [`Self::endpoint`] for more information.
92
    #[allow(clippy::missing_const_for_fn)] // destructors
93
    pub fn endpoint(mut self, endpoint: Endpoint) -> Self {
94
        self.endpoint = Some(endpoint);
95
        self
96
    }
97

            
98
4
    fn path_for_id(&self, storage_id: StorageId) -> String {
99
4
        let mut path = self.path.clone();
100
4
        if !path.is_empty() && !path.ends_with('/') {
101
1
            path.push('/');
102
3
        }
103
4
        path.push_str(&storage_id.to_string());
104
4
        path
105
4
    }
106

            
107
4
    async fn client(&self) -> aws_sdk_s3::Client {
108
4
        let region_provider = RegionProviderChain::first_try(self.region.clone())
109
4
            .or_default_provider()
110
4
            .or_else(Region::new("us-east-1"));
111
32
        let config = aws_config::from_env().load().await;
112
4
        if let Some(endpoint) = self.endpoint.clone() {
113
            Client::with_config(
114
4
                aws_smithy_client::Client::dyn_https(),
115
4
                aws_sdk_s3::Config::builder()
116
4
                    .endpoint_resolver(endpoint)
117
16
                    .region(region_provider.region().await)
118
4
                    .credentials_provider(config.credentials_provider().unwrap().clone())
119
4
                    .build(),
120
            )
121
        } else {
122
            aws_sdk_s3::Client::new(&config)
123
        }
124
4
    }
125
}
126

            
127
#[async_trait]
128
impl VaultKeyStorage for S3VaultKeyStorage {
129
    type Error = anyhow::Error;
130

            
131
1
    async fn set_vault_key_for(
132
1
        &self,
133
1
        storage_id: StorageId,
134
1
        key: KeyPair,
135
1
    ) -> Result<(), Self::Error> {
136
12
        let client = self.client().await;
137
1
        let key = key.to_bytes()?;
138
1
        client
139
1
            .put_object()
140
1
            .bucket(&self.bucket)
141
1
            .key(self.path_for_id(storage_id))
142
1
            .body(aws_sdk_s3::ByteStream::from(key.to_vec()))
143
7
            .send()
144
7
            .await?;
145
1
        Ok(())
146
2
    }
147

            
148
3
    async fn vault_key_for(&self, storage_id: StorageId) -> Result<Option<KeyPair>, Self::Error> {
149
36
        let client = self.client().await;
150
3
        match client
151
3
            .get_object()
152
3
            .bucket(&self.bucket)
153
3
            .key(self.path_for_id(storage_id))
154
18
            .send()
155
18
            .await
156
        {
157
2
            Ok(response) => {
158
2
                let bytes = response.body.collect().await?.into_bytes();
159
2
                let key =
160
2
                    KeyPair::from_bytes(&bytes).map_err(|err| anyhow::anyhow!(err.to_string()))?;
161
2
                Ok(Some(key))
162
            }
163
            Err(aws_sdk_s3::SdkError::ServiceError {
164
                err:
165
                    aws_sdk_s3::error::GetObjectError {
166
                        kind: GetObjectErrorKind::NoSuchKey(_),
167
                        ..
168
                    },
169
                ..
170
1
            }) => Ok(None),
171
            Err(err) => Err(anyhow::anyhow!(err)),
172
        }
173
6
    }
174
}
175

            
176
#[cfg(test)]
177
1
#[tokio::test]
178
1
async fn basic_test() {
179
1
    use bonsaidb_core::{
180
1
        connection::StorageConnection,
181
1
        document::KeyId,
182
1
        schema::SerializedCollection,
183
1
        test_util::{Basic, BasicSchema, TestDirectory},
184
1
    };
185
1
    use bonsaidb_local::{
186
1
        config::{Builder, StorageConfiguration},
187
1
        Storage,
188
1
    };
189
1
    use http::Uri;
190
1
    drop(dotenv::dotenv());
191

            
192
    macro_rules! env_var {
193
        ($name:expr) => {{
194
            match std::env::var($name) {
195
                Ok(value) => value,
196
                Err(_) => {
197
                    log::error!(
198
                        "Ignoring basic_test because of missing environment variable: {}",
199
                        $name
200
                    );
201
                    return;
202
                }
203
            }
204
        }};
205
    }
206

            
207
1
    let bucket = env_var!("S3_BUCKET");
208
1
    let endpoint = env_var!("S3_ENDPOINT");
209

            
210
1
    let directory = TestDirectory::new("bonsaidb-keystorage-s3-basic");
211
1

            
212
3
    let configuration = |prefix| {
213
3
        let mut vault_key_storage = S3VaultKeyStorage {
214
3
            bucket: bucket.clone(),
215
3
            endpoint: Some(Endpoint::immutable(Uri::try_from(&endpoint).unwrap())),
216
3
            ..S3VaultKeyStorage::default()
217
3
        };
218
3
        if let Some(prefix) = prefix {
219
1
            vault_key_storage = vault_key_storage.path(prefix);
220
2
        }
221

            
222
3
        StorageConfiguration::new(&directory)
223
3
            .vault_key_storage(vault_key_storage)
224
3
            .default_encryption_key(KeyId::Master)
225
3
            .with_schema::<BasicSchema>()
226
3
            .unwrap()
227
3
    };
228
1
    let document = {
229
42
        let bonsai = Storage::open(configuration(None)).await.unwrap();
230
1
        bonsai
231
2
            .create_database::<BasicSchema>("test", false)
232
2
            .await
233
1
            .unwrap();
234
1
        let db = bonsai.database::<BasicSchema>("test").await.unwrap();
235
1
        Basic::new("test").push_into(&db).await.unwrap()
236
    };
237

            
238
    {
239
        // Should be able to access the storage again
240
26
        let bonsai = Storage::open(configuration(None)).await.unwrap();
241

            
242
1
        let db = bonsai.database::<BasicSchema>("test").await.unwrap();
243
1
        let retrieved = Basic::get(document.header.id, &db)
244
1
            .await
245
1
            .unwrap()
246
1
            .expect("document not found");
247
1
        assert_eq!(document, retrieved);
248
    }
249

            
250
    // Verify that we can't access the storage again without the vault
251
1
    assert!(
252
23
        Storage::open(configuration(Some(String::from("path-prefix"))))
253
23
            .await
254
1
            .is_err()
255
    );
256
1
}