-
-
Notifications
You must be signed in to change notification settings - Fork 5
feat(gcs): Add resumable uploads #609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
840752a
a6411cf
acb8bf8
5d89b28
d9c6f82
e62c9c0
9289e52
8044720
62762a3
4f9e7c3
d5f882e
c7e184c
c830653
9470202
3049751
d4e0d06
6606025
c245f19
b9f9c73
154fef5
ab2cf80
c197ce1
2c54836
8ab626a
9220555
ab34dbb
88c4a1c
9bd703b
288b1ff
b95332a
b50835d
14c71ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,7 @@ use anyhow::Result; | |
| use figment::providers::{Env, Format, Serialized, Yaml}; | ||
| use objectstore_service::backend::local_fs::FileSystemConfig; | ||
| use objectstore_service::change_stream::CostTrackerConfig; | ||
| use objectstore_service::resumable::Encryptor; | ||
| use objectstore_types::auth::Permission; | ||
| use secrecy::{CloneableSecret, SecretBox, SerializableSecret, zeroize::Zeroize}; | ||
| use serde::{Deserialize, Serialize}; | ||
|
|
@@ -575,6 +576,8 @@ pub struct Config { | |
| /// - `OS__SERVICE__CONCURRENCY_QUEUE` | ||
| /// - `OS__SERVICE__CONCURRENCY_TIMEOUT` | ||
| /// - `OS__SERVICE__BULK_CONCURRENCY_PCT` | ||
| /// - `OS__SERVICE__RESUMABLE_TOKEN_ENCRYPTION__ACTIVE_KEY_ID` | ||
| /// - `OS__SERVICE__RESUMABLE_TOKEN_ENCRYPTION__KEY_FILES` | ||
| #[derive(Debug, Deserialize, Serialize)] | ||
| #[serde(default)] | ||
| pub struct Service { | ||
|
|
@@ -629,6 +632,61 @@ pub struct Service { | |
| /// | ||
| /// `60` | ||
| pub bulk_concurrency_pct: u32, | ||
|
|
||
| /// Persistent encryption keys for resumable-upload session tokens returned to clients. | ||
| /// | ||
| /// Tokens are always encrypted. When this is absent, Objectstore generates a fresh in-memory | ||
| /// AES-256 key at startup, so resumable sessions become invalid after a restart. Configure a | ||
| /// persistent keyring for sessions that must survive restarts. Keep old keys configured while | ||
| /// their sessions may still be active; removing a key intentionally invalidates those sessions. | ||
| /// | ||
| /// ```yaml | ||
| /// service: | ||
| /// resumable_token_encryption: | ||
| /// active_key_id: v1 | ||
| /// key_files: | ||
| /// v1: /var/run/secrets/objectstore/resumable-upload-v1 | ||
| /// ``` | ||
| pub resumable_token_encryption: Option<ResumableTokenEncryptionConfig>, | ||
| } | ||
|
|
||
| impl Service { | ||
| /// Loads and validates the configured resumable token encryption keys. | ||
| pub(crate) fn resumable_token_encryption(&self) -> Result<Option<Encryptor>> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As per #599, there's a way to include file contents directly via config. This is also what the new kafka config uses. Let's check if we can use this instead. |
||
| let Some(config) = &self.resumable_token_encryption else { | ||
| return Ok(None); | ||
| }; | ||
|
|
||
| let mut keys = BTreeMap::new(); | ||
| for (key_id, filename) in &config.key_files { | ||
| let bytes = std::fs::read(filename).map_err(|error| { | ||
| anyhow::anyhow!("reading resumable token key {filename:?}: {error}") | ||
| })?; | ||
| keys.insert(key_id.clone(), bytes); | ||
| } | ||
|
|
||
| Encryptor::new(config.active_key_id.clone(), keys).map(Some) | ||
| } | ||
| } | ||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
|
|
||
| /// AES-256-GCM keys used to protect externally visible resumable session tokens. | ||
| #[derive(Clone, Deserialize, Serialize)] | ||
| pub struct ResumableTokenEncryptionConfig { | ||
| /// Key used to encrypt newly created sessions. | ||
| pub active_key_id: String, | ||
| /// Files containing raw, exactly 32-byte AES-256 keys, indexed by rotation ID. | ||
| #[serde(default)] | ||
| pub key_files: BTreeMap<String, PathBuf>, | ||
| } | ||
|
|
||
| impl fmt::Debug for ResumableTokenEncryptionConfig { | ||
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| f.debug_struct("ResumableTokenEncryptionConfig") | ||
| .field("active_key_id", &self.active_key_id) | ||
| .field("key_ids", &self.key_files.keys().collect::<Vec<_>>()) | ||
| .field("key_files", &self.key_files) | ||
| .finish() | ||
| } | ||
| } | ||
|
|
||
| impl Default for Service { | ||
|
|
@@ -638,6 +696,7 @@ impl Default for Service { | |
| concurrency_queue: 0, | ||
| concurrency_timeout: Duration::from_secs(1), | ||
| bulk_concurrency_pct: 60, | ||
| resumable_token_encryption: None, | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -866,6 +925,78 @@ mod tests { | |
| }); | ||
| } | ||
|
|
||
| #[test] | ||
| fn resumable_token_encryption_loads_keys_from_files() { | ||
| let mut key_file = tempfile::NamedTempFile::new().unwrap(); | ||
| key_file.write_all(&[7; 32]).unwrap(); | ||
| let mut tempfile = tempfile::NamedTempFile::new().unwrap(); | ||
| tempfile | ||
| .write_all( | ||
| format!( | ||
| "service:\n resumable_token_encryption:\n active_key_id: v1\n key_files:\n v1: \"{}\"\n", | ||
| key_file.path().display(), | ||
| ) | ||
| .as_bytes(), | ||
| ) | ||
| .unwrap(); | ||
|
|
||
| figment::Jail::expect_with(|_jail| { | ||
| let config = Config::load(Some(tempfile.path())).unwrap(); | ||
| assert!( | ||
| config | ||
| .service | ||
| .resumable_token_encryption() | ||
| .unwrap() | ||
| .is_some() | ||
| ); | ||
|
|
||
| let debug = format!("{:?}", config.service); | ||
| assert!(debug.contains("v1")); | ||
| assert!(debug.contains(&key_file.path().display().to_string())); | ||
| assert!(!debug.contains("07070707")); | ||
| Ok(()) | ||
| }); | ||
| } | ||
|
|
||
| #[test] | ||
| fn resumable_token_encryption_rejects_invalid_configuration() { | ||
| let mut valid = tempfile::NamedTempFile::new().unwrap(); | ||
| valid.write_all(&[7; 32]).unwrap(); | ||
| let mut short = tempfile::NamedTempFile::new().unwrap(); | ||
| short.write_all(&[7; 31]).unwrap(); | ||
| let missing = valid.path().with_extension("missing"); | ||
| for yaml in [ | ||
| "service:\n resumable_token_encryption:\n active_key_id: v1\n".to_owned(), | ||
| format!( | ||
| "service:\n resumable_token_encryption:\n active_key_id: missing\n key_files:\n v1: \"{}\"\n", | ||
| valid.path().display(), | ||
| ), | ||
| format!( | ||
| "service:\n resumable_token_encryption:\n active_key_id: bad_key\n key_files:\n 'bad key': \"{}\"\n", | ||
| valid.path().display(), | ||
| ), | ||
| format!( | ||
| "service:\n resumable_token_encryption:\n active_key_id: v1\n key_files:\n v1: \"{}\"\n", | ||
| short.path().display(), | ||
| ), | ||
| format!( | ||
| "service:\n resumable_token_encryption:\n active_key_id: v1\n key_files:\n v1: \"{}\"\n", | ||
| missing.display(), | ||
| ), | ||
| ] { | ||
| let mut tempfile = tempfile::NamedTempFile::new().unwrap(); | ||
| tempfile.write_all(yaml.as_bytes()).unwrap(); | ||
| figment::Jail::expect_with(|_jail| { | ||
| let config = Config::load(Some(tempfile.path())).unwrap(); | ||
| assert!( | ||
| config.service.resumable_token_encryption().is_err(), | ||
| "accepted {yaml}" | ||
| ); | ||
| Ok(()) | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn configured_with_env_and_yaml() { | ||
| let mut tempfile = tempfile::NamedTempFile::new().unwrap(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,8 +3,6 @@ | |
| use axum::extract::{FromRequestParts, OptionalFromRequestParts, Query}; | ||
| use axum::http::{HeaderName, HeaderValue, request::Parts}; | ||
| use axum_extra::headers::{ContentLength, Error as HeaderError, Header}; | ||
| use base64::Engine as _; | ||
| use base64::engine::general_purpose::URL_SAFE_NO_PAD; | ||
| use futures_util::TryStreamExt; | ||
| use objectstore_service::error::Error as ServiceError; | ||
| use objectstore_service::stream::ClientStream; | ||
|
|
@@ -177,19 +175,8 @@ where | |
| } | ||
|
|
||
| fn decode_session_token(encoded: &str) -> ApiResult<SessionToken> { | ||
| let bytes = URL_SAFE_NO_PAD | ||
| .decode(encoded) | ||
| .map_err(|error| ApiError::map_client("invalid session token", error))?; | ||
|
|
||
| if URL_SAFE_NO_PAD.encode(&bytes) != encoded { | ||
| return Err(ApiError::client( | ||
| "session token must use unpadded base64 URL encoding", | ||
| )); | ||
| } | ||
|
|
||
| String::from_utf8(bytes) | ||
| .map(SessionToken::from) | ||
| .map_err(|error| ApiError::map_client("session token is not valid UTF-8", error)) | ||
| SessionToken::from_base64url(encoded) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Function can be removed and inlined. |
||
| .map_err(|error| ApiError::map_client("invalid session token", error)) | ||
| } | ||
|
|
||
| /// Confirms that a request neither declares nor streams a non-empty body. | ||
|
|
@@ -255,14 +242,14 @@ mod tests { | |
| #[test] | ||
| fn session_token_decodes_from_unpadded_base64url() { | ||
| assert_eq!( | ||
| decode_session_token("Li4vZXNjYXBl").unwrap().as_ref(), | ||
| "../escape" | ||
| decode_session_token("Li4vZXNjYXBl").unwrap().as_bytes(), | ||
| b"../escape" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn session_token_rejects_invalid_query_encodings() { | ||
| for invalid in ["%%%", "dG9rM24=", "_w"] { | ||
| for invalid in ["%%%", "dG9rM24="] { | ||
| assert!( | ||
| decode_session_token(invalid).is_err(), | ||
| "accepted {invalid:?}" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd suggest to make this configuration independent of resumable uploads. This is a key that can be used for any symmetric encryption business, which includes but does not have to be limited to resumable uploads.