Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions object_store/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,21 @@ To test the S3 integration against [localstack](https://localstack.cloud/)
First start up a container running localstack

```
$ podman run -d -p 4566:4566 localstack/localstack:2.0
$ LOCALSTACK_VERSION=sha256:a0b79cb2430f1818de2c66ce89d41bba40f5a1823410f5a7eaf3494b692eed97
$ podman run -d -p 4566:4566 localstack/localstack@$LOCALSTACK_VERSION
$ podman run -d -p 1338:1338 amazon/amazon-ec2-metadata-mock:v1.9.2 --imdsv2
```

Setup environment

```
export TEST_INTEGRATION=1
export OBJECT_STORE_AWS_DEFAULT_REGION=us-east-1
export OBJECT_STORE_AWS_ACCESS_KEY_ID=test
export OBJECT_STORE_AWS_SECRET_ACCESS_KEY=test
export OBJECT_STORE_AWS_ENDPOINT=http://localhost:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export OBJECT_STORE_BUCKET=test-bucket
export AWS_ENDPOINT=http://localhost:4566
export AWS_ALLOW_HTTP=true
export AWS_BUCKET_NAME=test-bucket
```

Create a bucket using the AWS CLI
Expand All @@ -66,6 +66,7 @@ Or directly with:

```
aws s3 mb s3://test-bucket --endpoint-url=http://localhost:4566
aws --endpoint-url=http://localhost:4566 dynamodb create-table --table-name test-table --key-schema AttributeName=path,KeyType=HASH AttributeName=etag,KeyType=RANGE --attribute-definitions AttributeName=path,AttributeType=S AttributeName=etag,AttributeType=S --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5
```

Run tests
Expand All @@ -74,6 +75,32 @@ Run tests
$ cargo test --features aws
```

#### Encryption tests

To create an encryption key for the tests, you can run the following command:

```
export AWS_SSE_KMS_KEY_ID=$(aws --endpoint-url=http://localhost:4566 \
kms create-key --description "test key" |
jq -r '.KeyMetadata.KeyId')
```

To run integration tests with encryption, you can set the following environment variables:

```
export AWS_SERVER_SIDE_ENCRYPTION=aws:kms
export AWS_SSE_BUCKET_KEY=false
cargo test --features aws
```

As well as:

```
unset AWS_SSE_BUCKET_KEY
export AWS_SERVER_SIDE_ENCRYPTION=aws:kms:dsse
cargo test --features aws
```

### Azure

To test the Azure integration
Expand Down
227 changes: 226 additions & 1 deletion object_store/src/aws/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use crate::client::TokenCredentialProvider;
use crate::config::ConfigValue;
use crate::{ClientConfigKey, ClientOptions, Result, RetryConfig, StaticCredentialProvider};
use itertools::Itertools;
use reqwest::header::{HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use snafu::{OptionExt, ResultExt, Snafu};
use std::str::FromStr;
Expand Down Expand Up @@ -83,6 +84,19 @@ enum Error {

#[snafu(display("Failed to parse the region for bucket '{}'", bucket))]
RegionParse { bucket: String },

#[snafu(display("Invalid encryption type: {}. Valid values are \"AES256\", \"sse:kms\", and \"sse:kms:dsse\".", passed))]
InvalidEncryptionType { passed: String },

#[snafu(display(
"Invalid encryption header values. Header: {}, source: {}",
header,
source
))]
InvalidEncryptionHeader {
header: &'static str,
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
}

impl From<Error> for crate::Error {
Expand Down Expand Up @@ -160,6 +174,10 @@ pub struct AmazonS3Builder {
conditional_put: Option<ConfigValue<S3ConditionalPut>>,
/// Ignore tags
disable_tagging: ConfigValue<bool>,
/// Encryption (See [`S3EncryptionConfigKey`])
encryption_type: Option<ConfigValue<S3EncryptionType>>,
encryption_kms_key_id: Option<String>,
encryption_bucket_key_enabled: Option<ConfigValue<bool>>,
}

/// Configuration keys for [`AmazonS3Builder`]
Expand Down Expand Up @@ -322,6 +340,9 @@ pub enum AmazonS3ConfigKey {

/// Client options
Client(ClientConfigKey),

/// Encryption options
Encryption(S3EncryptionConfigKey),
}

impl AsRef<str> for AmazonS3ConfigKey {
Expand All @@ -346,6 +367,7 @@ impl AsRef<str> for AmazonS3ConfigKey {
Self::ConditionalPut => "aws_conditional_put",
Self::DisableTagging => "aws_disable_tagging",
Self::Client(opt) => opt.as_ref(),
Self::Encryption(opt) => opt.as_ref(),
}
}
}
Expand Down Expand Up @@ -377,6 +399,13 @@ impl FromStr for AmazonS3ConfigKey {
"aws_disable_tagging" | "disable_tagging" => Ok(Self::DisableTagging),
// Backwards compatibility
"aws_allow_http" => Ok(Self::Client(ClientConfigKey::AllowHttp)),
"aws_server_side_encryption" => Ok(Self::Encryption(
S3EncryptionConfigKey::ServerSideEncryption,
)),
"aws_sse_kms_key_id" => Ok(Self::Encryption(S3EncryptionConfigKey::KmsKeyId)),
"aws_sse_bucket_key_enabled" => {
Ok(Self::Encryption(S3EncryptionConfigKey::BucketKeyEnabled))
}
_ => match s.parse() {
Ok(key) => Ok(Self::Client(key)),
Err(_) => Err(Error::UnknownConfigurationKey { key: s.into() }.into()),
Expand Down Expand Up @@ -486,6 +515,15 @@ impl AmazonS3Builder {
AmazonS3ConfigKey::ConditionalPut => {
self.conditional_put = Some(ConfigValue::Deferred(value.into()))
}
AmazonS3ConfigKey::Encryption(key) => match key {
S3EncryptionConfigKey::ServerSideEncryption => {
self.encryption_type = Some(ConfigValue::Deferred(value.into()))
}
S3EncryptionConfigKey::KmsKeyId => self.encryption_kms_key_id = Some(value.into()),
S3EncryptionConfigKey::BucketKeyEnabled => {
self.encryption_bucket_key_enabled = Some(ConfigValue::Deferred(value.into()))
}
},
};
self
}
Expand Down Expand Up @@ -531,6 +569,16 @@ impl AmazonS3Builder {
self.conditional_put.as_ref().map(ToString::to_string)
}
AmazonS3ConfigKey::DisableTagging => Some(self.disable_tagging.to_string()),
AmazonS3ConfigKey::Encryption(key) => match key {
S3EncryptionConfigKey::ServerSideEncryption => {
self.encryption_type.as_ref().map(ToString::to_string)
}
S3EncryptionConfigKey::KmsKeyId => self.encryption_kms_key_id.clone(),
S3EncryptionConfigKey::BucketKeyEnabled => self
.encryption_bucket_key_enabled
.as_ref()
.map(ToString::to_string),
},
}
}

Expand Down Expand Up @@ -759,6 +807,35 @@ impl AmazonS3Builder {
self
}

/// Use SSE-KMS for server side encryption.
pub fn with_sse_kms_encryption(mut self, kms_key_id: impl Into<String>) -> Self {
self.encryption_type = Some(ConfigValue::Parsed(S3EncryptionType::SseKms));
if let Some(kms_key_id) = kms_key_id.into().into() {
self.encryption_kms_key_id = Some(kms_key_id);
}
self
}

/// Use dual server side encryption for server side encryption.
pub fn with_dsse_kms_encryption(mut self, kms_key_id: impl Into<String>) -> Self {
self.encryption_type = Some(ConfigValue::Parsed(S3EncryptionType::DsseKms));
if let Some(kms_key_id) = kms_key_id.into().into() {
self.encryption_kms_key_id = Some(kms_key_id);
}
self
}

/// Set whether to enable bucket key for server side encryption. This overrides
/// the bucket default setting for bucket keys.
///
/// When bucket keys are disabled, each object is encrypted with a unique data key.
/// When bucket keys are enabled, a single data key is used for the entire bucket,
/// reducing overhead of encryption.
pub fn with_bucket_key(mut self, enabled: bool) -> Self {
self.encryption_bucket_key_enabled = Some(ConfigValue::Parsed(enabled));
self
}

/// Create a [`AmazonS3`] instance from the provided values,
/// consuming `self`.
pub fn build(mut self) -> Result<AmazonS3> {
Expand Down Expand Up @@ -882,6 +959,18 @@ impl AmazonS3Builder {
(None, None, false) => format!("https://s3.{region}.amazonaws.com/{bucket}"),
};

let encryption_headers = if let Some(encryption_type) = self.encryption_type {
S3EncryptionHeaders::try_new(
&encryption_type.get()?,
self.encryption_kms_key_id,
self.encryption_bucket_key_enabled
.map(|val| val.get())
.transpose()?,
)?
} else {
S3EncryptionHeaders::default()
};

let config = S3Config {
region,
endpoint: self.endpoint,
Expand All @@ -897,6 +986,7 @@ impl AmazonS3Builder {
checksum,
copy_if_not_exists,
conditional_put: put_precondition,
encryption_headers,
};

let client = Arc::new(S3Client::new(config)?);
Expand All @@ -912,6 +1002,120 @@ fn parse_bucket_az(bucket: &str) -> Option<&str> {
Some(bucket.strip_suffix("--x-s3")?.rsplit_once("--")?.1)
}

/// Encryption configuration options for S3.
///
/// These options are used to configure server-side encryption for S3 objects.
/// To configure them, pass them to [`AmazonS3Builder::with_config`].
///
/// Both [SSE-KMS] and [DSSE-KMS] are supported. [SSE-C] is not yet supported.
///
/// [SSE-KMS]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html
/// [DSSE-KMS]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingDSSEncryption.html
/// [SSE-C]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html
#[derive(PartialEq, Eq, Hash, Clone, Debug, Copy, Serialize, Deserialize)]
#[non_exhaustive]
pub enum S3EncryptionConfigKey {
/// Type of encryption to use. If set, must be one of "AES256", "aws:kms", or "aws:kms:dsse".
ServerSideEncryption,
/// The KMS key ID to use for server-side encryption. If set, ServerSideEncryption
/// must be "aws:kms" or "aws:kms:dsse".
KmsKeyId,
/// If set to true, will use the bucket's default KMS key for server-side encryption.
/// If set to false, will disable the use of the bucket's default KMS key for server-side encryption.
BucketKeyEnabled,
}

impl AsRef<str> for S3EncryptionConfigKey {
fn as_ref(&self) -> &str {
match self {
Self::ServerSideEncryption => "aws_server_side_encryption",
Self::KmsKeyId => "aws_sse_kms_key_id",
Self::BucketKeyEnabled => "aws_sse_bucket_key_enabled",
}
}
}

#[derive(Debug, Clone)]
enum S3EncryptionType {
S3,
SseKms,
DsseKms,
}

impl crate::config::Parse for S3EncryptionType {
fn parse(s: &str) -> Result<Self> {
match s {
"AES256" => Ok(Self::S3),
"aws:kms" => Ok(Self::SseKms),
"aws:kms:dsse" => Ok(Self::DsseKms),
_ => Err(Error::InvalidEncryptionType { passed: s.into() }.into()),
}
}
}

impl From<&S3EncryptionType> for &'static str {
fn from(value: &S3EncryptionType) -> Self {
match value {
S3EncryptionType::S3 => "AES256",
S3EncryptionType::SseKms => "aws:kms",
S3EncryptionType::DsseKms => "aws:kms:dsse",
}
}
}

impl std::fmt::Display for S3EncryptionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.into())
}
}

/// A sequence of headers to be sent for write requests that specify server-side
/// encryption.
///
/// Whether these headers are sent depends on both the kind of encryption set
/// and the kind of request being made.
#[derive(Default, Clone)]
pub struct S3EncryptionHeaders(pub HeaderMap);

impl std::fmt::Debug for S3EncryptionHeaders {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// TODO: if we take a user-provided key, hide the key from debug output.
f.debug_map().entries(self.0.iter()).finish()
}
}

impl S3EncryptionHeaders {
fn try_new(
encryption_type: &S3EncryptionType,
key_id: Option<String>,
bucket_key_enabled: Option<bool>,
) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(
"x-amz-server-side-encryption",
HeaderValue::from_static(encryption_type.into()),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HeaderValue has an API called set_sensitive. We can adopt this to prevent accidental leakage of our header value.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. Given that, we can just derive Debug for S3EncryptionHeaders. I've made a note that for SSE-C we should use set_sensitive. The current headers are not sensitive.

);
if let Some(key_id) = key_id {
headers.insert(
"x-amz-server-side-encryption-aws-kms-key-id",
key_id
.try_into()
.map_err(|err| Error::InvalidEncryptionHeader {
header: "kms-key-id",
source: Box::new(err),
})?,
);
}
if let Some(bucket_key_enabled) = bucket_key_enabled {
headers.insert(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can only insert this key while bucket_key_enabled is true? This can save one extra long header.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting it to false and not setting it at all have different meanings. If you don't set it, then it uses the default setting at the bucket level. But if you set it to false for the specific object, then it will definitely not use bucket key for that particular object. But most of the time, I don't think people will set this header, so it won't be added.

"x-amz-server-side-encryption-bucket-key-enabled",
HeaderValue::from_static(if bucket_key_enabled { "true" } else { "false" }),
);
}
Ok(Self(headers))
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -967,7 +1171,10 @@ mod tests {
.with_config(AmazonS3ConfigKey::DefaultRegion, &aws_default_region)
.with_config(AmazonS3ConfigKey::Endpoint, &aws_endpoint)
.with_config(AmazonS3ConfigKey::Token, &aws_session_token)
.with_config(AmazonS3ConfigKey::UnsignedPayload, "true");
.with_config(AmazonS3ConfigKey::UnsignedPayload, "true")
.with_config("aws_server_side_encryption".parse().unwrap(), "AES256")
.with_config("aws_sse_kms_key_id".parse().unwrap(), "some_key_id")
.with_config("aws_sse_bucket_key_enabled".parse().unwrap(), "true");

assert_eq!(
builder
Expand Down Expand Up @@ -1003,6 +1210,24 @@ mod tests {
.unwrap(),
"true"
);
assert_eq!(
builder
.get_config_value(&"aws_server_side_encryption".parse().unwrap())
.unwrap(),
"AES256"
);
assert_eq!(
builder
.get_config_value(&"aws_sse_kms_key_id".parse().unwrap())
.unwrap(),
"some_key_id"
);
assert_eq!(
builder
.get_config_value(&"aws_sse_bucket_key_enabled".parse().unwrap())
.unwrap(),
"true"
);
}

#[test]
Expand Down
Loading