From 1711f1f0b2efe7c84b8d51ddcd10510a091a56fe Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 16 Jul 2026 13:56:49 -0700 Subject: [PATCH 1/2] feat(cubestore): Support sending x-amz-server-side-encryption header on S3 requests (CUBESTORE_S3_SSE) (#11276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When CUBESTORE_S3_SSE is set (AES256, aws:kms, or aws:kms:dsse), the S3 remote FS adds the x-amz-server-side-encryption header to every request via rust-s3 extra_headers. Some AWS Organizations SCPs deny s3:PutObject requests that lack this header, even when the bucket has default encryption enabled — without it, all CubeStore uploads (chunks, metastore, cachestore) fail with HTTP 403. The header is applied both at initial bucket construction and on every credentials-refresh bucket rebuild, so it survives credential rotation. Co-authored-by: Claude Fable 5 --- .../configuration/environment-variables.mdx | 11 ++ rust/cubestore/cubestore/src/remotefs/s3.rs | 113 +++++++++++++++++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/docs-mintlify/reference/configuration/environment-variables.mdx b/docs-mintlify/reference/configuration/environment-variables.mdx index fc6914f04a9e2..46239b2644284 100644 --- a/docs-mintlify/reference/configuration/environment-variables.mdx +++ b/docs-mintlify/reference/configuration/environment-variables.mdx @@ -1953,6 +1953,17 @@ The name of a bucket in AWS S3. Required when using AWS S3. | -------------------------------------- | ---------------------- | --------------------- | | [A valid AWS region][aws-docs-regions] | N/A | N/A | +## `CUBESTORE_S3_SSE` + +When set, Cube Store sends the `x-amz-server-side-encryption` header with this +value on every AWS S3 request. Optional. Useful when an AWS Organizations +service control policy denies `s3:PutObject` requests that lack this header, +even if the bucket has default encryption enabled. + +| Possible Values | Default in Development | Default in Production | +| --------------------------------- | ---------------------- | --------------------- | +| `AES256`, `aws:kms`, `aws:kms:dsse` | N/A | N/A | + ## `CUBESTORE_S3_SUB_PATH` The path in a AWS S3 bucket to store pre-aggregations. Optional. diff --git a/rust/cubestore/cubestore/src/remotefs/s3.rs b/rust/cubestore/cubestore/src/remotefs/s3.rs index 7e3ef76ae6fc1..98de7ef7837ad 100644 --- a/rust/cubestore/cubestore/src/remotefs/s3.rs +++ b/rust/cubestore/cubestore/src/remotefs/s3.rs @@ -33,6 +33,10 @@ pub struct S3RemoteFs { /// STS AssumeRoleWithWebIdentity with the JWT inside it. web_identity_token_file: Option, web_identity_role_arn: Option, + /// When set, every request carries `x-amz-server-side-encryption` with this + /// value. Some AWS Organizations SCPs deny `s3:PutObject` unless the header + /// is present, even when the bucket has default encryption. + server_side_encryption: Option, } impl fmt::Debug for S3RemoteFs { @@ -89,7 +93,13 @@ impl S3RemoteFs { let region = region.parse::().map_err(|e| { CubeError::internal(format!("Failed to parse Region '{}': {}", region, e)) })?; - let bucket = Bucket::new(&bucket_name, region.clone(), credentials)?; + let server_side_encryption = server_side_encryption_from_env()?; + let bucket = new_bucket( + &bucket_name, + region.clone(), + credentials, + &server_side_encryption, + )?; let fs = Arc::new(Self { dir, bucket: arc_swap::ArcSwap::new(Arc::new(bucket)), @@ -97,6 +107,7 @@ impl S3RemoteFs { delete_mut: Mutex::new(()), web_identity_token_file: token_file, web_identity_role_arn: role_arn, + server_side_encryption, }); spawn_creds_refresh_loop(access_key, secret_key, bucket_name, region, &fs); @@ -104,6 +115,39 @@ impl S3RemoteFs { } } +/// Values S3 accepts in `x-amz-server-side-encryption`. +const ALLOWED_SSE_VALUES: &[&str] = &["AES256", "aws:kms", "aws:kms:dsse"]; + +fn server_side_encryption_from_env() -> Result, CubeError> { + parse_sse_value(env::var("CUBESTORE_S3_SSE").ok()) +} + +fn parse_sse_value(value: Option) -> Result, CubeError> { + match value { + None => Ok(None), + Some(v) if v.is_empty() => Ok(None), + Some(v) if ALLOWED_SSE_VALUES.contains(&v.as_str()) => Ok(Some(v)), + Some(v) => Err(CubeError::user(format!( + "Invalid CUBESTORE_S3_SSE value '{}'. Expected one of: {}", + v, + ALLOWED_SSE_VALUES.join(", ") + ))), + } +} + +fn new_bucket( + bucket_name: &str, + region: Region, + credentials: Credentials, + server_side_encryption: &Option, +) -> Result { + let mut bucket = Bucket::new(bucket_name, region, credentials)?; + if let Some(sse) = server_side_encryption { + bucket.add_header("x-amz-server-side-encryption", sse); + } + Ok(bucket) +} + fn spawn_creds_refresh_loop( access_key: Option, secret_key: Option, @@ -113,6 +157,7 @@ fn spawn_creds_refresh_loop( ) { let token_file = fs.web_identity_token_file.clone(); let role_arn = fs.web_identity_role_arn.clone(); + let server_side_encryption = fs.server_side_encryption.clone(); let is_web_identity = token_file.is_some() && role_arn.is_some(); // Web identity STS credentials expire in ~1 hour, so poll the token file @@ -186,7 +231,7 @@ fn spawn_creds_refresh_loop( continue; } }; - let b = match Bucket::new(&bucket_name, region.clone(), c) { + let b = match new_bucket(&bucket_name, region.clone(), c, &server_side_encryption) { Ok(b) => b, Err(e) => { log::error!("Failed to refresh S3 credentials: {}", e); @@ -513,3 +558,67 @@ impl S3RemoteFs { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_sse_value_accepts_allowed_values() { + assert_eq!(parse_sse_value(None).unwrap(), None); + assert_eq!(parse_sse_value(Some("".to_string())).unwrap(), None); + assert_eq!( + parse_sse_value(Some("AES256".to_string())).unwrap(), + Some("AES256".to_string()) + ); + assert_eq!( + parse_sse_value(Some("aws:kms".to_string())).unwrap(), + Some("aws:kms".to_string()) + ); + assert_eq!( + parse_sse_value(Some("aws:kms:dsse".to_string())).unwrap(), + Some("aws:kms:dsse".to_string()) + ); + } + + #[test] + fn parse_sse_value_rejects_unknown_values() { + assert!(parse_sse_value(Some("aes256".to_string())).is_err()); + assert!(parse_sse_value(Some("true".to_string())).is_err()); + } + + #[test] + fn new_bucket_applies_sse_header() { + let credentials = Credentials::new(Some("key"), Some("secret"), None, None, None).unwrap(); + let bucket = new_bucket( + "test-bucket", + "us-east-1".parse().unwrap(), + credentials, + &Some("AES256".to_string()), + ) + .unwrap(); + assert_eq!( + bucket + .extra_headers() + .get("x-amz-server-side-encryption") + .map(|v| v.to_str().unwrap()), + Some("AES256") + ); + } + + #[test] + fn new_bucket_without_sse_has_no_header() { + let credentials = Credentials::new(Some("key"), Some("secret"), None, None, None).unwrap(); + let bucket = new_bucket( + "test-bucket", + "us-east-1".parse().unwrap(), + credentials, + &None, + ) + .unwrap(); + assert!(bucket + .extra_headers() + .get("x-amz-server-side-encryption") + .is_none()); + } +} From 760a64537f62ad527214381794ad30cc711090c8 Mon Sep 17 00:00:00 2001 From: Alex Qyoun-ae <4062971+MazterQyou@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:40:24 +0400 Subject: [PATCH 2/2] fix(cubesql): Support `ORDER BY` over unprojected columns in CTEs (#11261) Signed-off-by: Alex Qyoun-ae <4062971+MazterQyou@users.noreply.github.com> --- packages/cubejs-backend-native/Cargo.lock | 12 ++--- rust/cubesql/Cargo.lock | 12 ++--- rust/cubesql/cubesql/Cargo.toml | 2 +- rust/cubesql/cubesql/src/compile/mod.rs | 55 +++++++++++++++++++++++ 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/packages/cubejs-backend-native/Cargo.lock b/packages/cubejs-backend-native/Cargo.lock index ab8bd90eda5eb..262aea1e97b40 100644 --- a/packages/cubejs-backend-native/Cargo.lock +++ b/packages/cubejs-backend-native/Cargo.lock @@ -897,7 +897,7 @@ dependencies = [ [[package]] name = "cube-ext" version = "1.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "arrow 13.0.0", "chrono", @@ -1073,7 +1073,7 @@ checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" [[package]] name = "datafusion" version = "7.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "ahash 0.7.8", "arrow 13.0.0", @@ -1106,7 +1106,7 @@ dependencies = [ [[package]] name = "datafusion-common" version = "7.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "arrow 13.0.0", "ordered-float 2.10.1", @@ -1117,7 +1117,7 @@ dependencies = [ [[package]] name = "datafusion-data-access" version = "1.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "async-trait", "chrono", @@ -1130,7 +1130,7 @@ dependencies = [ [[package]] name = "datafusion-expr" version = "7.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "ahash 0.7.8", "arrow 13.0.0", @@ -1141,7 +1141,7 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" version = "7.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "ahash 0.7.8", "arrow 13.0.0", diff --git a/rust/cubesql/Cargo.lock b/rust/cubesql/Cargo.lock index f80ed21997e4f..3a90a0f75c9c5 100644 --- a/rust/cubesql/Cargo.lock +++ b/rust/cubesql/Cargo.lock @@ -698,7 +698,7 @@ dependencies = [ [[package]] name = "cube-ext" version = "1.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "arrow", "chrono", @@ -822,7 +822,7 @@ dependencies = [ [[package]] name = "datafusion" version = "7.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "ahash 0.7.8", "arrow", @@ -855,7 +855,7 @@ dependencies = [ [[package]] name = "datafusion-common" version = "7.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "arrow", "ordered-float 2.10.0", @@ -866,7 +866,7 @@ dependencies = [ [[package]] name = "datafusion-data-access" version = "1.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "async-trait", "chrono", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "datafusion-expr" version = "7.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "ahash 0.7.8", "arrow", @@ -890,7 +890,7 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" version = "7.0.0" -source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=1c073dfa64c10949df15064aa7eb71ce513e725a#1c073dfa64c10949df15064aa7eb71ce513e725a" +source = "git+https://github.com/cube-js/arrow-datafusion.git?rev=ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b#ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b" dependencies = [ "ahash 0.7.8", "arrow", diff --git a/rust/cubesql/cubesql/Cargo.toml b/rust/cubesql/cubesql/Cargo.toml index 7c6affc7b3890..d040b049a1c4f 100644 --- a/rust/cubesql/cubesql/Cargo.toml +++ b/rust/cubesql/cubesql/Cargo.toml @@ -10,7 +10,7 @@ homepage = "https://cube.dev" [dependencies] arc-swap = "1" -datafusion = { git = 'https://github.com/cube-js/arrow-datafusion.git', rev = "1c073dfa64c10949df15064aa7eb71ce513e725a", default-features = false, features = [ +datafusion = { git = 'https://github.com/cube-js/arrow-datafusion.git', rev = "ed911e3d05215a4c97e6c977d5c3bd7f9cc33b8b", default-features = false, features = [ "regex_expressions", "unicode_expressions", ] } diff --git a/rust/cubesql/cubesql/src/compile/mod.rs b/rust/cubesql/cubesql/src/compile/mod.rs index d8967cd5d22cc..f50496e37d182 100644 --- a/rust/cubesql/cubesql/src/compile/mod.rs +++ b/rust/cubesql/cubesql/src/compile/mod.rs @@ -19006,6 +19006,61 @@ LIMIT {{ limit }}{% endif %}"#.to_string(), .contains("must appear in the GROUP BY clause or be used in an aggregate function")); } + #[tokio::test] + async fn test_cte_order_by_unprojected_column() { + init_testing_logger(); + + // The CTE's ORDER BY references `cnt`, which is not in the CTE's projection; + // the sort column must be resolved through the CTE's aliased projection + // during DF post-processing planning + let query_plan = convert_select_to_query_plan( + // language=PostgreSQL + r#" + WITH t1 AS ( + SELECT + customer_gender, + taxful_total_price AS price, + MEASURE(count) AS cnt + FROM KibanaSampleDataEcommerce + GROUP BY 1, 2 + ), + t2 AS ( + SELECT customer_gender, price + FROM t1 + ORDER BY customer_gender, cnt DESC + ) + SELECT * FROM t2 + LIMIT 100 + "# + .to_string(), + DatabaseProtocol::PostgreSQL, + ) + .await; + + let logical_plan = query_plan.as_logical_plan(); + let request = logical_plan.find_cube_scan().request; + assert_eq!( + request.dimensions, + Some(vec![ + "KibanaSampleDataEcommerce.customer_gender".to_string(), + "KibanaSampleDataEcommerce.taxful_total_price".to_string(), + ]) + ); + assert_eq!( + request.order, + Some(vec![ + vec![ + "KibanaSampleDataEcommerce.customer_gender".to_string(), + "asc".to_string(), + ], + vec![ + "KibanaSampleDataEcommerce.count".to_string(), + "desc".to_string(), + ], + ]) + ); + } + #[tokio::test] async fn test_set_cache_mode() -> Result<(), CubeError> { if !Rewriter::sql_push_down_enabled() {