mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-28 16:07:05 +00:00
fix(sse): document and lock anonymous denial under KMS key policy (#6739)
This commit is contained in:
@@ -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<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
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<reqwest::Response, reqwest::Error> {
|
||||
local_http_client().get(object_url(env, key)).send().await
|
||||
}
|
||||
|
||||
async fn anonymous_put(env: &LocalKMSTestEnvironment, key: &str) -> Result<reqwest::Response, reqwest::Error> {
|
||||
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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user