From 7c4e514ec9ede1dd1d50f91c0750b10f1cbc5fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=B0=8F=E9=B8=AD?= Date: Thu, 27 Aug 2026 18:34:34 +0800 Subject: [PATCH] fix(sse): document and lock anonymous denial under KMS key policy (#6739) --- .../src/kms/kms_anonymous_enforcement_test.rs | 220 ++++++++++++++++++ crates/e2e_test/src/kms/mod.rs | 3 + docs/operations/kms-per-key-authorization.md | 6 +- rustfs/src/storage/sse.rs | 27 ++- 4 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 crates/e2e_test/src/kms/kms_anonymous_enforcement_test.rs diff --git a/crates/e2e_test/src/kms/kms_anonymous_enforcement_test.rs b/crates/e2e_test/src/kms/kms_anonymous_enforcement_test.rs new file mode 100644 index 000000000..f40a73b7d --- /dev/null +++ b/crates/e2e_test/src/kms/kms_anonymous_enforcement_test.rs @@ -0,0 +1,220 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Anonymous access to SSE-KMS objects under per-key authorization. +//! +//! Locks both halves of the anonymous contract decided in backlog#2028 (D4): +//! +//! - **Enforcement on**: anonymous requests hold no `kms` grants, so a public +//! bucket policy does not let them read SSE-KMS objects or write through an +//! SSE-KMS default-encryption rule. Both fail with `AccessDenied`. +//! - **Enforcement off** (the default): bucket policy alone governs anonymous +//! access, matching the pre-enforcement behavior — public SSE-KMS objects are +//! decrypted and served, and anonymous writes are encrypted under the default +//! key. +//! +//! The denial today is emergent — an empty-account principal falling through to +//! the IAM default deny — so without this file a refactor of principal +//! construction or policy evaluation could silently flip it. Each test carries a +//! plaintext-object positive control: a denial proves nothing while the bucket +//! policy has not propagated. + +use super::common::{LocalKMSTestEnvironment, create_key_with_specific_id}; +use crate::common::{init_logging, local_http_client}; +use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::types::{ + ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule, +}; +use std::time::Duration; + +type TestResult = Result<(), Box>; + +const DEFAULT_KEY: &str = "kms-anon-default-key"; +const BUCKET: &str = "kms-anon-enforcement"; +const PLAIN_OBJECT: &str = "plain.txt"; +const ENCRYPTED_OBJECT: &str = "encrypted.txt"; +const PAYLOAD: &[u8] = b"kms anonymous enforcement payload"; + +/// How long a bucket policy change may take to reach the request path. +const POLICY_PROPAGATION: Duration = Duration::from_secs(20); + +/// Start a local-KMS server and build the public-bucket fixture. +/// +/// The bucket holds a plaintext object (the positive control), an SSE-KMS +/// object, an SSE-KMS default-encryption rule, and a bucket policy opening +/// `GetObject`/`PutObject` to everyone. The enforcement switch defaults to off, +/// so the enforcing case has to set it explicitly. +async fn start_public_sse_kms_bucket(env: &mut LocalKMSTestEnvironment, enforce: bool) -> TestResult { + create_key_with_specific_id(&env.kms_keys_dir, DEFAULT_KEY).await?; + + let key_dir = env.kms_keys_dir.clone(); + let args = vec![ + "--kms-enable", + "--kms-backend", + "local", + "--kms-key-dir", + key_dir.as_str(), + "--kms-default-key-id", + DEFAULT_KEY, + ]; + let mut envs = vec![("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true")]; + if enforce { + envs.push(("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true")); + } + env.base_env.start_rustfs_server_with_env(args, &envs).await?; + env.base_env.create_test_bucket(BUCKET).await?; + + let owner = env.base_env.create_s3_client(); + + owner + .put_object() + .bucket(BUCKET) + .key(PLAIN_OBJECT) + .body(ByteStream::from_static(PAYLOAD)) + .send() + .await?; + owner + .put_object() + .bucket(BUCKET) + .key(ENCRYPTED_OBJECT) + .body(ByteStream::from_static(PAYLOAD)) + .server_side_encryption(ServerSideEncryption::AwsKms) + .ssekms_key_id(DEFAULT_KEY) + .send() + .await?; + + let encryption_config = ServerSideEncryptionConfiguration::builder() + .rules( + ServerSideEncryptionRule::builder() + .apply_server_side_encryption_by_default( + ServerSideEncryptionByDefault::builder() + .sse_algorithm(ServerSideEncryption::AwsKms) + .kms_master_key_id(DEFAULT_KEY) + .build()?, + ) + .build(), + ) + .build()?; + owner + .put_bucket_encryption() + .bucket(BUCKET) + .server_side_encryption_configuration(encryption_config) + .send() + .await?; + + let policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "PublicReadWrite", + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:GetObject", "s3:PutObject"], + "Resource": [format!("arn:aws:s3:::{BUCKET}/*")] + }] + }) + .to_string(); + owner.put_bucket_policy().bucket(BUCKET).policy(&policy).send().await?; + let _ = owner.delete_public_access_block().bucket(BUCKET).send().await; + + Ok(()) +} + +fn object_url(env: &LocalKMSTestEnvironment, key: &str) -> String { + format!("{}/{BUCKET}/{key}", env.base_env.url) +} + +async fn anonymous_get(env: &LocalKMSTestEnvironment, key: &str) -> Result { + local_http_client().get(object_url(env, key)).send().await +} + +async fn anonymous_put(env: &LocalKMSTestEnvironment, key: &str) -> Result { + local_http_client().put(object_url(env, key)).body(PAYLOAD).send().await +} + +/// Retry the plaintext read until the public bucket policy is live. +async fn wait_for_public_read(env: &LocalKMSTestEnvironment) -> TestResult { + let deadline = tokio::time::Instant::now() + POLICY_PROPAGATION; + loop { + let status = anonymous_get(env, PLAIN_OBJECT).await?.status(); + if status.as_u16() == 200 { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!("positive control never became readable: anonymous GET {PLAIN_OBJECT} -> {status}").into()); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +async fn assert_anonymous_denied(response: reqwest::Response, what: &str) -> TestResult { + let status = response.status().as_u16(); + let body = response.text().await?; + assert_eq!(status, 403, "{what} must be denied, got {status}: {body}"); + assert!(body.contains("AccessDenied"), "{what} must carry AccessDenied: {body}"); + Ok(()) +} + +/// Enforcement on: a public bucket policy does not exempt anonymous requests +/// from per-key authorization, on either the read or the default-encryption +/// write path. +#[tokio::test(flavor = "multi_thread")] +async fn anonymous_sse_kms_denied_under_enforcement() -> TestResult { + init_logging(); + + let mut env = LocalKMSTestEnvironment::new().await?; + start_public_sse_kms_bucket(&mut env, true).await?; + wait_for_public_read(&env).await?; + + let read = anonymous_get(&env, ENCRYPTED_OBJECT).await?; + assert_anonymous_denied(read, "anonymous GET of an SSE-KMS object").await?; + + let write = anonymous_put(&env, "anon-write.txt").await?; + assert_anonymous_denied(write, "anonymous PUT through an SSE-KMS default-encryption rule").await?; + + Ok(()) +} + +/// Enforcement off (the default): bucket policy alone governs anonymous access, +/// and the default-encryption rule still encrypts anonymous writes. +#[tokio::test(flavor = "multi_thread")] +async fn anonymous_sse_kms_governed_by_bucket_policy_without_enforcement() -> TestResult { + init_logging(); + + let mut env = LocalKMSTestEnvironment::new().await?; + start_public_sse_kms_bucket(&mut env, false).await?; + wait_for_public_read(&env).await?; + + let read = anonymous_get(&env, ENCRYPTED_OBJECT).await?; + assert_eq!(read.status().as_u16(), 200, "anonymous GET of a public SSE-KMS object must succeed"); + assert_eq!(read.bytes().await?.as_ref(), PAYLOAD, "the object must be served decrypted"); + + let write = anonymous_put(&env, "anon-write.txt").await?; + assert_eq!(write.status().as_u16(), 200, "anonymous PUT to a public bucket must succeed"); + + let stored = env + .base_env + .create_s3_client() + .head_object() + .bucket(BUCKET) + .key("anon-write.txt") + .send() + .await?; + assert_eq!( + stored.server_side_encryption(), + Some(&ServerSideEncryption::AwsKms), + "the anonymous write must be encrypted by the bucket default rule" + ); + + Ok(()) +} diff --git a/crates/e2e_test/src/kms/mod.rs b/crates/e2e_test/src/kms/mod.rs index 23bc2ff3d..ad5a96858 100644 --- a/crates/e2e_test/src/kms/mod.rs +++ b/crates/e2e_test/src/kms/mod.rs @@ -57,6 +57,9 @@ mod copy_object_version_restore_sse_test; #[cfg(test)] mod configured_roundtrip_test; +#[cfg(test)] +mod kms_anonymous_enforcement_test; + #[cfg(test)] mod kms_authorization_negative_matrix_test; diff --git a/docs/operations/kms-per-key-authorization.md b/docs/operations/kms-per-key-authorization.md index c2ec3d22e..51e577d34 100644 --- a/docs/operations/kms-per-key-authorization.md +++ b/docs/operations/kms-per-key-authorization.md @@ -81,7 +81,7 @@ Scope and exemptions: - **SSE-KMS only.** SSE-S3 wraps its data key with a server-owned key the caller never names, and SSE-C never reaches KMS; both are exempt, matching AWS. - **The resolved key**, not the header. A bucket default encryption rule naming a KMS key is authorized the same way an explicit `x-amz-server-side-encryption-aws-kms-key-id` header is. -- **Anonymous requests are exempt.** They have no identity policy to evaluate, and denying them would break public buckets holding SSE-KMS objects. They remain governed by bucket policy. +- **Anonymous requests are denied.** An anonymous caller has no identity policy and therefore holds no `kms` grants, so under enforcement every anonymous read or write of an SSE-KMS object fails with `AccessDenied` — even when a bucket policy makes the bucket public. This matches AWS, where anonymous requests cannot use SSE-KMS objects at all, and it keeps the per-key gate meaningful: were anonymous requests exempt, any denied identity could bypass the gate on a public bucket by simply dropping its credentials. **A public bucket serving SSE-KMS objects is incompatible with enforcement** — serve public content unencrypted or under SSE-S3 instead. With enforcement off (the default), anonymous access to SSE-KMS objects remains governed by bucket policy alone. The server warns once per process when it first denies an anonymous request; per-request denials appear on audit entries (`kmsOutcome=failure`, `kmsErrorClass=access_denied`, empty requester identity) and at debug level. - **Internal work is exempt.** Replication, lifecycle transitions, healing and the scanner run as the system principal. - **Authorization runs before key state is checked**, so a denial cannot be used to probe whether a key exists, is disabled, or is pending deletion. The response is always `AccessDenied`. - **Multipart uploads are authorized at create time**, where the session data key is generated. Part uploads and completion reuse that envelope and are not re-authorized against the destination key. @@ -89,13 +89,13 @@ Scope and exemptions: ## Migration -Data-path enforcement is **off by default in this release** because it changes the outcome of requests that succeed today: an identity holding only `s3:PutObject` can currently encrypt under any key. Turning it on without preparing policies will produce `AccessDenied` on working workloads. +Data-path enforcement is **off by default** because it changes the outcome of requests that succeed today: an identity holding only `s3:PutObject` can currently encrypt under any key. Turning it on without preparing policies will produce `AccessDenied` on working workloads. ```bash RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY=true ``` -The server logs the configured mode once at startup, and warns while enforcement is off. A later release defaults it to enabled. +The server logs the configured mode once at startup, and warns while enforcement is off. Enforcement stays opt-in: there is no roadmap to flip the default. Recommended sequence: diff --git a/rustfs/src/storage/sse.rs b/rustfs/src/storage/sse.rs index 97a116d93..1cf400dd7 100644 --- a/rustfs/src/storage/sse.rs +++ b/rustfs/src/storage/sse.rs @@ -961,9 +961,9 @@ fn sse_kms_key_policy_enforced(principal: Option<&SseKmsPrincipal>) -> bool { /// Report the configured SSE-KMS authorization mode once, at startup. /// -/// The disabled case warns rather than logs: it is the compatibility default for this -/// release only, and operators need the lead time to grant the kms actions before the -/// default flips. +/// The disabled case warns rather than logs: while enforcement is off, any identity +/// allowed to write an object can encrypt it under any key, and operators should hear +/// about that even though disabled is the long-term default. pub(crate) fn log_sse_kms_key_policy_mode() { if sse_kms_key_policy_enforced(None) { tracing::info!( @@ -984,7 +984,7 @@ pub(crate) fn log_sse_kms_key_policy_mode() { "SSE-KMS requests are not authorized against the KMS key they name; any identity allowed to \ write an object may encrypt it under any key, and any identity allowed to read it may have it \ decrypted. Grant kms:GenerateDataKey and kms:Decrypt on the keys your workloads use, then set \ - {ENV_RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY}=true. A later release defaults this to enabled." + {ENV_RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY}=true." ); } @@ -1027,6 +1027,25 @@ async fn authorize_sse_kms_key( "Principal is not authorized for the KMS key resolved for this request" ); + // One warn per process, not per request: anonymous denials are driven by + // unauthenticated traffic, so a per-request warn would let anyone flood the + // log. Per-request detail stays on the audit entry and the debug event above. + if principal.account.is_empty() { + static ANONYMOUS_DENIAL_WARNED: std::sync::Once = std::sync::Once::new(); + ANONYMOUS_DENIAL_WARNED.call_once(|| { + tracing::warn!( + component = LOG_COMPONENT_STORAGE, + subsystem = LOG_SUBSYSTEM_SSE, + event = "sse_kms_anonymous_key_authorization_denied", + action = ?action, + "Anonymous requests are being denied by SSE-KMS per-key authorization: anonymous \ + callers hold no kms grants, so a public bucket serving SSE-KMS objects is \ + incompatible with {ENV_RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY}=true. Reported once per \ + process; per-request denials are on audit entries and at debug level." + ); + }); + } + Err(ApiError { code: S3ErrorCode::AccessDenied, message: "Access Denied".to_string(),