From fc3896f479961b62f64f7e248cca640d06fbc7c3 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Sun, 2 Aug 2026 03:33:33 +0800 Subject: [PATCH] feat(policy): add built-in KMS role policies and a negative authorization matrix (#5588) * feat(policy): add built-in KMS role policies KMSKeyAdministrator, KMSKeyUser and KMSAuditor ship as canned identity policies so operators can express KMS role separation without hand-writing the resource grammar. They grant only kms actions, so they compose with an existing data-plane policy, and none of them confers kms:Configure, kms:ServiceControl, kms:ClearCache, kms:Backup or kms:Restore. * docs(kms): document per-key KMS authorization and the role templates * test(kms): add an end-to-end negative authorization matrix Covers the admin and SSE-KMS planes for a wrong identity, a wrong key, a wrong action and an explicit Deny, each preceded by a positive control so a denial cannot be an unpropagated policy. SSE-S3 and unencrypted objects are asserted to stay exempt. * test(replication): pin the SSE-KMS contract with per-key authorization on The replication worker carries no request identity, so it must stay exempt from SSE-KMS key authorization. Running the existing contract with the switch enabled makes a regression in that exemption visible here. --- .../kms_authorization_negative_matrix_test.rs | 483 ++++++++++++++++++ crates/e2e_test/src/kms/mod.rs | 3 + .../src/replication_extension_test.rs | 5 + crates/policy/src/policy/policy.rs | 282 +++++++++- docs/operations/kms-backend-security.md | 2 +- docs/operations/kms-per-key-authorization.md | 107 ++++ 6 files changed, 880 insertions(+), 2 deletions(-) create mode 100644 crates/e2e_test/src/kms/kms_authorization_negative_matrix_test.rs create mode 100644 docs/operations/kms-per-key-authorization.md diff --git a/crates/e2e_test/src/kms/kms_authorization_negative_matrix_test.rs b/crates/e2e_test/src/kms/kms_authorization_negative_matrix_test.rs new file mode 100644 index 000000000..bc6ef5b24 --- /dev/null +++ b/crates/e2e_test/src/kms/kms_authorization_negative_matrix_test.rs @@ -0,0 +1,483 @@ +// 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. + +//! Negative authorization matrix for per-key KMS access control. +//! +//! Every case here is an end-to-end denial that the pre-`kms` resource server +//! allowed, so a regression that reopens one of them fails this file rather than +//! only a unit test. The matrix varies one dimension at a time: +//! +//! - **wrong identity**: a caller holding S3 rights but no `kms` grant at all +//! - **wrong key**: a caller scoped to key A naming key B +//! - **wrong action**: a caller holding `kms:GenerateDataKey` but not `kms:Decrypt` +//! (and, on the admin plane, `kms:DisableKey` but not `kms:RotateKey`) +//! - **wrong context**: an explicit `Deny` beating a wildcard `Allow`, and SSE-S3 +//! staying exempt from `kms` authorization +//! +//! Each matrix opens with a positive control. Without it a denial proves nothing: +//! an identity whose policy has not propagated yet is denied everything. + +use super::common::{LocalKMSTestEnvironment, create_key_with_specific_id}; +use crate::common::{admin_ok, admin_request, init_logging}; +use aws_sdk_s3::Client; +use aws_sdk_s3::config::{Config, Credentials, Region}; +use aws_sdk_s3::error::ProvideErrorMetadata; +use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::types::ServerSideEncryption; +use serial_test::serial; +use std::time::Duration; +use tracing::info; + +type TestResult = Result<(), Box>; + +const ALLOWED_KEY: &str = "kms-matrix-allowed-key"; +const OTHER_KEY: &str = "kms-matrix-other-key"; +const BUCKET: &str = "kms-authz-matrix"; +const SECRET: &str = "kms-matrix-secret"; +const PAYLOAD: &[u8] = b"kms authorization matrix payload"; + +/// How long an identity change may take to reach the request path. +const IAM_PROPAGATION: Duration = Duration::from_secs(20); + +fn s3_client(url: &str, access_key: &str, secret_key: &str) -> Client { + let config = Config::builder() + .credentials_provider(Credentials::new(access_key, secret_key, None, None, "kms-authz-matrix")) + .region(Region::new("us-east-1")) + .endpoint_url(url) + .force_path_style(true) + .behavior_version_latest() + .build(); + Client::from_conf(config) +} + +/// Start a server whose SSE-KMS data path authorizes against the named key. +/// +/// The enforcement switch defaults to off for compatibility, so it has to be set +/// explicitly; without it every negative case below would silently pass as an allow. +async fn start_enforcing_server(env: &mut LocalKMSTestEnvironment, extra_env: &[(&str, &str)]) -> TestResult { + create_key_with_specific_id(&env.kms_keys_dir, ALLOWED_KEY).await?; + create_key_with_specific_id(&env.kms_keys_dir, OTHER_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", + ALLOWED_KEY, + ]; + + let mut envs = vec![("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true")]; + envs.extend_from_slice(extra_env); + + env.base_env.start_rustfs_server_with_env(args, &envs).await?; + Ok(()) +} + +/// Create `user` with `policy_document` attached under a canned policy of the same name. +async fn provision_user(env: &LocalKMSTestEnvironment, user: &str, policy_document: &str) -> TestResult { + admin_ok( + &env.base_env, + http::Method::PUT, + &format!("/rustfs/admin/v3/add-canned-policy?name={user}"), + Some(policy_document.to_string()), + ) + .await?; + provision_user_with_policy(env, user, user).await +} + +/// Create `user` and attach an existing policy (built-in or canned) by name. +async fn provision_user_with_policy(env: &LocalKMSTestEnvironment, user: &str, policy_name: &str) -> TestResult { + admin_ok( + &env.base_env, + http::Method::PUT, + &format!("/rustfs/admin/v3/add-user?accessKey={user}"), + Some(serde_json::json!({ "secretKey": SECRET, "status": "enabled" }).to_string()), + ) + .await?; + admin_ok( + &env.base_env, + http::Method::PUT, + &format!("/rustfs/admin/v3/set-user-or-group-policy?policyName={policy_name}&userOrGroup={user}&isGroup=false"), + None, + ) + .await?; + Ok(()) +} + +/// The S3 half of every data-path policy below: full object access, no KMS grant. +fn s3_full_access_statement() -> serde_json::Value { + serde_json::json!({ + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": ["arn:aws:s3:::*"] + }) +} + +fn policy_document(statements: Vec) -> String { + serde_json::json!({ "Version": "2012-10-17", "Statement": statements }).to_string() +} + +async fn put_sse_kms(client: &Client, key: &str, kms_key_id: &str) -> Result<(), aws_sdk_s3::Error> { + client + .put_object() + .bucket(BUCKET) + .key(key) + .body(ByteStream::from_static(PAYLOAD)) + .server_side_encryption(ServerSideEncryption::AwsKms) + .ssekms_key_id(kms_key_id) + .send() + .await + .map(|_| ()) + .map_err(aws_sdk_s3::Error::from) +} + +/// Assert the operation failed with `AccessDenied` rather than any other error. +/// +/// A bare `is_err` would also accept `KMSKeyDisabled` or an internal error, which +/// would hide both a leak of key state and an outage masquerading as a denial. +fn assert_access_denied(result: Result, what: &str) { + let error = result.expect_err(&format!("{what} must be denied")); + assert_eq!(error.code(), Some("AccessDenied"), "{what} must fail with AccessDenied: {error:?}"); +} + +/// Retry an SSE-KMS write until the identity's policy has reached the request path. +async fn wait_for_sse_kms_write(client: &Client, key: &str, kms_key_id: &str) -> TestResult { + let deadline = tokio::time::Instant::now() + IAM_PROPAGATION; + loop { + match put_sse_kms(client, key, kms_key_id).await { + Ok(()) => return Ok(()), + Err(error) if tokio::time::Instant::now() >= deadline => { + return Err(format!("positive control never became authorized: {error:?}").into()); + } + Err(_) => tokio::time::sleep(Duration::from_millis(500)).await, + } + } +} + +/// Retry an admin call until it stops returning 403, i.e. the policy is live. +async fn wait_for_admin_success( + env: &LocalKMSTestEnvironment, + user: &str, + method: http::Method, + path: &str, + body: Option, +) -> TestResult { + let deadline = tokio::time::Instant::now() + IAM_PROPAGATION; + loop { + let (status, response) = admin_request(&env.base_env.url, method.clone(), path, body.clone(), user, SECRET).await?; + if status.is_success() { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!("positive control never became authorized: {method} {path} -> {status} {response}").into()); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +async fn assert_admin_denied( + env: &LocalKMSTestEnvironment, + user: &str, + method: http::Method, + path: &str, + body: Option, + what: &str, +) -> TestResult { + let (status, response) = admin_request(&env.base_env.url, method, path, body, user, SECRET).await?; + assert_eq!(status.as_u16(), 403, "{what} must be denied, got {status}: {response}"); + assert!(response.contains("AccessDenied"), "{what} must carry AccessDenied: {response}"); + Ok(()) +} + +fn disable_body(key_id: &str) -> String { + serde_json::json!({ "key_id": key_id }).to_string() +} + +/// Data-path matrix: SSE-KMS writes and reads are authorized against the resolved key. +#[tokio::test] +#[serial] +async fn sse_kms_per_key_authorization_negative_matrix() -> TestResult { + init_logging(); + + let mut env = LocalKMSTestEnvironment::new().await?; + start_enforcing_server(&mut env, &[("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true")]).await?; + env.base_env.create_test_bucket(BUCKET).await?; + + // Scoped to ALLOWED_KEY only. + provision_user( + &env, + "kmsmatrixscoped", + &policy_document(vec![ + s3_full_access_statement(), + serde_json::json!({ + "Effect": "Allow", + "Action": ["kms:GenerateDataKey", "kms:Decrypt"], + "Resource": [format!("arn:aws:kms:::key/{ALLOWED_KEY}")] + }), + ]), + ) + .await?; + // S3 rights only: the identity shape that existed before per-key authorization. + provision_user(&env, "kmsmatrixs3only", &policy_document(vec![s3_full_access_statement()])).await?; + // May wrap a data key but may never unwrap one. + provision_user( + &env, + "kmsmatrixwriter", + &policy_document(vec![ + s3_full_access_statement(), + serde_json::json!({ + "Effect": "Allow", + "Action": ["kms:GenerateDataKey"], + "Resource": ["arn:aws:kms:::*"] + }), + ]), + ) + .await?; + // Wildcard allow, explicit deny on one key. + provision_user( + &env, + "kmsmatrixdenied", + &policy_document(vec![ + s3_full_access_statement(), + serde_json::json!({ + "Effect": "Allow", + "Action": ["kms:*"], + "Resource": ["arn:aws:kms:::*"] + }), + serde_json::json!({ + "Effect": "Deny", + "Action": ["kms:*"], + "Resource": [format!("arn:aws:kms:::key/{OTHER_KEY}")] + }), + ]), + ) + .await?; + + let scoped = s3_client(&env.base_env.url, "kmsmatrixscoped", SECRET); + let s3_only = s3_client(&env.base_env.url, "kmsmatrixs3only", SECRET); + let writer = s3_client(&env.base_env.url, "kmsmatrixwriter", SECRET); + let denied = s3_client(&env.base_env.url, "kmsmatrixdenied", SECRET); + + // --- positive control ----------------------------------------------------- + wait_for_sse_kms_write(&scoped, "scoped/allowed", ALLOWED_KEY).await?; + let read = scoped.get_object().bucket(BUCKET).key("scoped/allowed").send().await?; + assert_eq!(read.body.collect().await?.into_bytes().as_ref(), PAYLOAD); + info!("positive control: scoped identity may write and read under its own key"); + + // --- wrong key ------------------------------------------------------------ + assert_access_denied( + put_sse_kms(&scoped, "scoped/other", OTHER_KEY).await, + "SSE-KMS write under a key outside the identity's scope", + ); + + // --- wrong identity ------------------------------------------------------- + assert_access_denied( + put_sse_kms(&s3_only, "s3only/allowed", ALLOWED_KEY).await, + "SSE-KMS write by an identity holding no kms grant", + ); + // The object the scoped identity wrote is readable by its owner only. + assert_access_denied( + s3_only + .get_object() + .bucket(BUCKET) + .key("scoped/allowed") + .send() + .await + .map(|_| ()) + .map_err(aws_sdk_s3::Error::from), + "SSE-KMS read by an identity holding no kms grant", + ); + + // --- wrong action --------------------------------------------------------- + wait_for_sse_kms_write(&writer, "writer/allowed", ALLOWED_KEY).await?; + assert_access_denied( + writer + .get_object() + .bucket(BUCKET) + .key("writer/allowed") + .send() + .await + .map(|_| ()) + .map_err(aws_sdk_s3::Error::from), + "SSE-KMS read by an identity holding kms:GenerateDataKey but not kms:Decrypt", + ); + + // --- wrong context: explicit Deny beats a wildcard Allow ------------------- + wait_for_sse_kms_write(&denied, "denied/allowed", ALLOWED_KEY).await?; + assert_access_denied( + put_sse_kms(&denied, "denied/other", OTHER_KEY).await, + "SSE-KMS write under a key covered by an explicit Deny", + ); + + // --- wrong context: SSE-S3 is out of scope -------------------------------- + // SSE-S3 wraps its data key with a server-owned key the caller never names, so + // it must stay reachable for an identity with no kms grant at all. + s3_only + .put_object() + .bucket(BUCKET) + .key("s3only/sse-s3") + .body(ByteStream::from_static(PAYLOAD)) + .server_side_encryption(ServerSideEncryption::Aes256) + .send() + .await?; + let sse_s3_read = s3_only.get_object().bucket(BUCKET).key("s3only/sse-s3").send().await?; + assert_eq!(sse_s3_read.body.collect().await?.into_bytes().as_ref(), PAYLOAD); + + // ... and so must an unencrypted object. + s3_only + .put_object() + .bucket(BUCKET) + .key("s3only/plain") + .body(ByteStream::from_static(PAYLOAD)) + .send() + .await?; + s3_only.get_object().bucket(BUCKET).key("s3only/plain").send().await?; + + Ok(()) +} + +/// Admin-plane matrix: KMS key endpoints are authorized against the key they name. +/// +/// Runs without the SSE enforcement switch: admin scoping is unconditional, and +/// leaving the switch off proves the two planes are independent. +#[tokio::test] +#[serial] +async fn kms_admin_per_key_authorization_negative_matrix() -> TestResult { + init_logging(); + + let mut env = LocalKMSTestEnvironment::new().await?; + start_enforcing_server(&mut env, &[]).await?; + + // Built-in role templates, attached by name. + provision_user_with_policy(&env, "kmsmatrixkeyadmin", "KMSKeyAdministrator").await?; + provision_user_with_policy(&env, "kmsmatrixauditor", "KMSAuditor").await?; + // A narrowed copy of the administrator template, scoped to one key. + provision_user( + &env, + "kmsmatrixscopedadmin", + &policy_document(vec![serde_json::json!({ + "Effect": "Allow", + "Action": ["kms:DisableKey", "kms:EnableKey"], + "Resource": [format!("arn:aws:kms:::key/{ALLOWED_KEY}")] + })]), + ) + .await?; + + // --- positive control ----------------------------------------------------- + wait_for_admin_success( + &env, + "kmsmatrixkeyadmin", + http::Method::POST, + "/rustfs/admin/v3/kms/keys/disable", + Some(disable_body(OTHER_KEY)), + ) + .await?; + admin_request( + &env.base_env.url, + http::Method::POST, + "/rustfs/admin/v3/kms/keys/enable", + Some(disable_body(OTHER_KEY)), + "kmsmatrixkeyadmin", + SECRET, + ) + .await?; + + // --- wrong action: the administrator template withholds service-wide powers - + assert_admin_denied( + &env, + "kmsmatrixkeyadmin", + http::Method::GET, + "/rustfs/admin/v3/kms/config", + None, + "KMSKeyAdministrator reading the KMS backend configuration (kms:Configure)", + ) + .await?; + assert_admin_denied( + &env, + "kmsmatrixkeyadmin", + http::Method::GET, + "/rustfs/admin/v3/kms/backup", + None, + "KMSKeyAdministrator exporting a backup bundle (kms:Backup)", + ) + .await?; + // Separation of duties: managing a key never implies using it. + assert_admin_denied( + &env, + "kmsmatrixkeyadmin", + http::Method::POST, + "/rustfs/admin/v3/kms/generate-data-key", + Some(serde_json::json!({ "key_id": ALLOWED_KEY }).to_string()), + "KMSKeyAdministrator generating a data key (kms:GenerateDataKey)", + ) + .await?; + + // --- wrong action: the auditor template is read-only ---------------------- + wait_for_admin_success(&env, "kmsmatrixauditor", http::Method::GET, "/rustfs/admin/v3/kms/keys", None).await?; + assert_admin_denied( + &env, + "kmsmatrixauditor", + http::Method::POST, + "/rustfs/admin/v3/kms/keys/disable", + Some(disable_body(ALLOWED_KEY)), + "KMSAuditor disabling a key (kms:DisableKey)", + ) + .await?; + + // --- wrong key ------------------------------------------------------------ + wait_for_admin_success( + &env, + "kmsmatrixscopedadmin", + http::Method::POST, + "/rustfs/admin/v3/kms/keys/disable", + Some(disable_body(ALLOWED_KEY)), + ) + .await?; + assert_admin_denied( + &env, + "kmsmatrixscopedadmin", + http::Method::POST, + "/rustfs/admin/v3/kms/keys/disable", + Some(disable_body(OTHER_KEY)), + "key-scoped administrator disabling a key outside its scope", + ) + .await?; + // --- wrong action, same key ---------------------------------------------- + assert_admin_denied( + &env, + "kmsmatrixscopedadmin", + http::Method::POST, + "/rustfs/admin/v3/kms/keys/rotate", + Some(disable_body(ALLOWED_KEY)), + "key-scoped administrator rotating a key it may only enable and disable", + ) + .await?; + + // --- wrong identity ------------------------------------------------------- + provision_user(&env, "kmsmatrixnokms", &policy_document(vec![s3_full_access_statement()])).await?; + assert_admin_denied( + &env, + "kmsmatrixnokms", + http::Method::POST, + "/rustfs/admin/v3/kms/keys/disable", + Some(disable_body(ALLOWED_KEY)), + "identity holding no kms grant disabling a key", + ) + .await?; + + Ok(()) +} diff --git a/crates/e2e_test/src/kms/mod.rs b/crates/e2e_test/src/kms/mod.rs index 6a0512bea..dd371480d 100644 --- a/crates/e2e_test/src/kms/mod.rs +++ b/crates/e2e_test/src/kms/mod.rs @@ -53,3 +53,6 @@ mod copy_object_version_restore_sse_test; #[cfg(test)] mod configured_roundtrip_test; + +#[cfg(test)] +mod kms_authorization_negative_matrix_test; diff --git a/crates/e2e_test/src/replication_extension_test.rs b/crates/e2e_test/src/replication_extension_test.rs index 85cb9ddf9..6b483c284 100644 --- a/crates/e2e_test/src/replication_extension_test.rs +++ b/crates/e2e_test/src/replication_extension_test.rs @@ -1507,6 +1507,10 @@ async fn build_sse_replication_pair( ("RUSTFS_KMS_KEY_DIR", source_kms_key_dir.as_str()), ("RUSTFS_KMS_DEFAULT_KEY_ID", REPL17_KMS_KEY_ID), ("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"), + // Per-key KMS authorization is on so this contract is pinned in the + // configuration replication will eventually ship with: the replication + // worker carries no request identity and must stay exempt. + ("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true"), ]); } source_env.start_rustfs_server_with_env(vec![], &source_process_env).await?; @@ -1519,6 +1523,7 @@ async fn build_sse_replication_pair( ("RUSTFS_KMS_KEY_DIR", target_kms_key_dir.as_str()), ("RUSTFS_KMS_DEFAULT_KEY_ID", REPL17_KMS_KEY_ID), ("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true"), + ("RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY", "true"), ]); } target_env diff --git a/crates/policy/src/policy/policy.rs b/crates/policy/src/policy/policy.rs index 7188d3228..156e4d231 100644 --- a/crates/policy/src/policy/policy.rs +++ b/crates/policy/src/policy/policy.rs @@ -365,8 +365,48 @@ pub mod default { use super::Policy; + /// Name of the built-in policy granting KMS key lifecycle management. + pub const KMS_KEY_ADMINISTRATOR: &str = "KMSKeyAdministrator"; + /// Name of the built-in policy granting cryptographic use of KMS keys. + pub const KMS_KEY_USER: &str = "KMSKeyUser"; + /// Name of the built-in policy granting read-only visibility into KMS keys. + pub const KMS_AUDITOR: &str = "KMSAuditor"; + + /// Every KMS key, in the resource grammar identity policies use. + /// + /// The built-in KMS policies ship unscoped so they behave like the other canned + /// policies; an operator narrows a copy to `arn:aws:kms:::key/` per workload. + const ALL_KMS_KEYS: &str = "*"; + + /// A KMS statement allowing `actions` on every key. + fn kms_allow(actions: Vec) -> Statement { + Statement { + sid: "".into(), + effect: Effect::Allow, + actions: ActionSet(actions), + not_actions: ActionSet(Default::default()), + resources: ResourceSet(vec![Resource::Kms(ALL_KMS_KEYS.into())]), + conditions: Functions::default(), + ..Default::default() + } + } + + /// The `sts:AssumeRole` grant every canned policy carries so an STS session may + /// assume it. + fn assume_role_allow() -> Statement { + Statement { + sid: "".into(), + effect: Effect::Allow, + actions: ActionSet(vec![Action::StsAction(StsAction::AssumeRoleAction)]), + not_actions: ActionSet(Default::default()), + resources: ResourceSet(Default::default()), + conditions: Functions::default(), + ..Default::default() + } + } + #[allow(clippy::incompatible_msrv)] - pub static DEFAULT_POLICIES: LazyLock<[(&'static str, Policy); 5]> = LazyLock::new(|| { + pub static DEFAULT_POLICIES: LazyLock<[(&'static str, Policy); 8]> = LazyLock::new(|| { [ ( "readwrite", @@ -534,6 +574,65 @@ pub mod default { ], }, ), + // KMS role templates. They deliberately carry no S3 or admin grants, so an + // operator combines one with a data-plane policy ("readwrite,KMSKeyUser"). + // + // None of them grants kms:Configure, kms:ServiceControl, kms:ClearCache, + // kms:Backup or kms:Restore: those act on the KMS service or on the material + // of every key at once, which is a cluster-administration power rather than a + // key-management one, and they stay with consoleAdmin. Key creation currently + // shares kms:Configure with backend configuration, so it stays there too. + ( + KMS_KEY_ADMINISTRATOR, + Policy { + id: "".into(), + version: DEFAULT_VERSION.into(), + statements: vec![ + // Separation of duties: a key administrator governs a key's + // lifecycle but is never able to encrypt or decrypt with it. + kms_allow(vec![ + Action::KmsAction(KmsAction::DescribeKeyAction), + Action::KmsAction(KmsAction::ListKeysAction), + Action::KmsAction(KmsAction::EnableKeyAction), + Action::KmsAction(KmsAction::DisableKeyAction), + Action::KmsAction(KmsAction::RotateKeyAction), + Action::KmsAction(KmsAction::DeleteKeyAction), + ]), + assume_role_allow(), + ], + }, + ), + ( + KMS_KEY_USER, + Policy { + id: "".into(), + version: DEFAULT_VERSION.into(), + statements: vec![ + // The two actions the SSE-KMS data path evaluates, plus the + // metadata read a client needs to tell which key it is using. + kms_allow(vec![ + Action::KmsAction(KmsAction::GenerateDataKeyAction), + Action::KmsAction(KmsAction::DecryptAction), + Action::KmsAction(KmsAction::DescribeKeyAction), + ]), + assume_role_allow(), + ], + }, + ), + ( + KMS_AUDITOR, + Policy { + id: "".into(), + version: DEFAULT_VERSION.into(), + statements: vec![ + kms_allow(vec![ + Action::KmsAction(KmsAction::DescribeKeyAction), + Action::KmsAction(KmsAction::ListKeysAction), + ]), + assume_role_allow(), + ], + }, + ), ] }); } @@ -542,6 +641,7 @@ pub mod default { mod test { use super::*; use crate::error::Result; + use crate::policy::action::{AdminAction, KmsAction, S3Action}; #[tokio::test] async fn test_parse_policy() -> Result<()> { @@ -709,6 +809,186 @@ mod test { } } + // ------------------------------------------------------------------------ + // Built-in KMS role templates + // ------------------------------------------------------------------------ + + fn default_policy(name: &str) -> &'static Policy { + default::DEFAULT_POLICIES + .iter() + .find_map(|(candidate, policy)| (*candidate == name).then_some(policy)) + .unwrap_or_else(|| panic!("built-in policy {name} should exist")) + } + + /// Evaluate `policy` for `account` against `action` on `key_id`. + /// + /// Mirrors the admin and SSE call sites: the requested key identifier travels in + /// `object` with `bucket` left empty. An empty `key_id` is the unscoped call. + async fn kms_allows(policy: &Policy, account: &str, action: KmsAction, key_id: &str) -> bool { + let conditions = HashMap::new(); + let claims = HashMap::new(); + policy + .is_allowed(&Args { + account, + groups: &None, + action: Action::KmsAction(action), + bucket: "", + conditions: &conditions, + is_owner: false, + object: key_id, + claims: &claims, + deny_only: false, + }) + .await + } + + const KMS_LIFECYCLE_ACTIONS: [KmsAction; 4] = [ + KmsAction::EnableKeyAction, + KmsAction::DisableKeyAction, + KmsAction::RotateKeyAction, + KmsAction::DeleteKeyAction, + ]; + + const KMS_CRYPTO_ACTIONS: [KmsAction; 2] = [KmsAction::GenerateDataKeyAction, KmsAction::DecryptAction]; + + /// Actions that act on the service or on every key's material at once. No role + /// template may confer them. + const KMS_CLUSTER_ADMIN_ACTIONS: [KmsAction; 6] = [ + KmsAction::AllActions, + KmsAction::ConfigureAction, + KmsAction::ServiceControlAction, + KmsAction::ClearCacheAction, + KmsAction::BackupAction, + KmsAction::RestoreAction, + ]; + + const KMS_ROLE_TEMPLATES: [&str; 3] = [default::KMS_KEY_ADMINISTRATOR, default::KMS_KEY_USER, default::KMS_AUDITOR]; + + #[tokio::test] + async fn kms_key_administrator_manages_keys_but_cannot_use_them() { + let policy = default_policy(default::KMS_KEY_ADMINISTRATOR); + + for action in KMS_LIFECYCLE_ACTIONS { + assert!( + kms_allows(policy, "keyadmin", action, "app-key").await, + "KMSKeyAdministrator should allow {action:?}" + ); + } + assert!(kms_allows(policy, "keyadmin", KmsAction::DescribeKeyAction, "app-key").await); + assert!(kms_allows(policy, "keyadmin", KmsAction::ListKeysAction, "").await); + + for action in KMS_CRYPTO_ACTIONS { + assert!( + !kms_allows(policy, "keyadmin", action, "app-key").await, + "KMSKeyAdministrator must not allow {action:?}" + ); + } + } + + #[tokio::test] + async fn kms_key_user_uses_keys_but_cannot_manage_them() { + let policy = default_policy(default::KMS_KEY_USER); + + for action in KMS_CRYPTO_ACTIONS { + assert!( + kms_allows(policy, "appuser", action, "app-key").await, + "KMSKeyUser should allow {action:?}" + ); + } + assert!(kms_allows(policy, "appuser", KmsAction::DescribeKeyAction, "app-key").await); + + for action in KMS_LIFECYCLE_ACTIONS { + assert!( + !kms_allows(policy, "appuser", action, "app-key").await, + "KMSKeyUser must not allow {action:?}" + ); + } + assert!(!kms_allows(policy, "appuser", KmsAction::ListKeysAction, "").await); + } + + #[tokio::test] + async fn kms_auditor_only_reads_key_metadata() { + let policy = default_policy(default::KMS_AUDITOR); + + assert!(kms_allows(policy, "auditor", KmsAction::DescribeKeyAction, "app-key").await); + assert!(kms_allows(policy, "auditor", KmsAction::ListKeysAction, "").await); + + for action in KMS_LIFECYCLE_ACTIONS.iter().chain(KMS_CRYPTO_ACTIONS.iter()) { + assert!( + !kms_allows(policy, "auditor", *action, "app-key").await, + "KMSAuditor must not allow {action:?}" + ); + } + } + + #[tokio::test] + async fn kms_role_templates_withhold_service_and_bundle_actions() { + for name in KMS_ROLE_TEMPLATES { + let policy = default_policy(name); + for action in KMS_CLUSTER_ADMIN_ACTIONS { + assert!( + !kms_allows(policy, "someone", action, "app-key").await, + "{name} must not allow {action:?}" + ); + } + } + } + + #[tokio::test] + async fn kms_role_templates_grant_nothing_outside_kms() { + let conditions = HashMap::new(); + let claims = HashMap::new(); + let foreign_actions = [ + Action::S3Action(S3Action::GetObjectAction), + Action::S3Action(S3Action::PutObjectAction), + Action::AdminAction(AdminAction::ServerInfoAdminAction), + ]; + + for name in KMS_ROLE_TEMPLATES { + let policy = default_policy(name); + for action in &foreign_actions { + let allowed = policy + .is_allowed(&Args { + account: "someone", + groups: &None, + action: *action, + bucket: "any-bucket", + conditions: &conditions, + is_owner: false, + object: "any-object", + claims: &claims, + deny_only: false, + }) + .await; + assert!(!allowed, "{name} must not allow {action:?}"); + } + } + } + + /// The narrowing an operator is told to apply must actually deny the other keys. + #[tokio::test] + async fn narrowed_kms_role_template_denies_other_keys() -> Result<()> { + let narrowed = Policy::parse_config( + br#"{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["kms:GenerateDataKey", "kms:Decrypt", "kms:DescribeKey"], + "Resource": ["arn:aws:kms:::key/reports-*"] + } + ] +}"#, + )?; + + assert!(kms_allows(&narrowed, "appuser", KmsAction::GenerateDataKeyAction, "reports-2026").await); + assert!(kms_allows(&narrowed, "appuser", KmsAction::DecryptAction, "reports-2026").await); + assert!(!kms_allows(&narrowed, "appuser", KmsAction::DecryptAction, "payroll-2026").await); + assert!(!kms_allows(&narrowed, "appuser", KmsAction::DisableKeyAction, "reports-2026").await); + + Ok(()) + } + #[tokio::test] async fn test_deny_only_checks_only_deny_statements() -> Result<()> { let data = r#" diff --git a/docs/operations/kms-backend-security.md b/docs/operations/kms-backend-security.md index 1de0b71a9..68e958f72 100644 --- a/docs/operations/kms-backend-security.md +++ b/docs/operations/kms-backend-security.md @@ -2,7 +2,7 @@ RustFS ships several KMS backends. They differ not only in deployment effort but in **where master key material lives and who can read it**. Pick a backend based on the confidentiality boundary you need, not on the name alone. -For how the Vault backends authenticate (static token, AppRole, Vault Agent token file) and how credential refresh and the fail-closed window behave, see the [Vault KMS authentication runbook](vault-kms-authentication.md). For what may be claimed about the cryptographic implementations themselves, see [Cryptographic compliance positioning](kms-cryptographic-compliance.md). +For how the Vault backends authenticate (static token, AppRole, Vault Agent token file) and how credential refresh and the fail-closed window behave, see the [Vault KMS authentication runbook](vault-kms-authentication.md). For what may be claimed about the cryptographic implementations themselves, see [Cryptographic compliance positioning](kms-cryptographic-compliance.md). For which RustFS identities may manage or use a given key, see [Per-key KMS authorization](kms-per-key-authorization.md). ## Backend comparison diff --git a/docs/operations/kms-per-key-authorization.md b/docs/operations/kms-per-key-authorization.md new file mode 100644 index 000000000..c2ec3d22e --- /dev/null +++ b/docs/operations/kms-per-key-authorization.md @@ -0,0 +1,107 @@ +# Per-key KMS authorization + +RustFS authorizes KMS access with identity policies. A statement may name the keys it applies to, so a grant such as `kms:DisableKey` no longer implies every key in the cluster, and an SSE-KMS request is checked against the key it actually resolves to. + +This page covers the resource grammar, the built-in role templates, the two enforcement planes, and the migration path. For where master key material lives per backend, see [KMS backend security properties](kms-backend-security.md). + +## Resource grammar + +KMS resources use the same empty-account ARN form as S3 resources: + +| Pattern | Matches | +| --- | --- | +| `arn:aws:kms:::key/` | exactly that key | +| `arn:aws:kms:::key/app-*` | every key whose id starts with `app-` | +| `arn:aws:kms:::*` | every key | +| `arn:aws:kms:::alias/` | reserved; matches nothing until alias resolution ships | + +Rules worth knowing before writing a policy: + +- A statement mixing `kms:` actions with `s3:` actions is rejected. Put KMS grants in their own statement. +- A statement carrying a KMS resource with non-KMS actions is rejected. +- A KMS statement with **no** resource matches every key. This is the legacy form and stays valid, so existing policies keep their current effect. +- A KMS statement whose resources are S3 ARNs predates KMS resources. It is still loadable, but the S3 patterns never constrained key access, so evaluation ignores them, treats the statement as unscoped, and logs a warning. Rewrite these with real KMS resources. +- `PutBucketPolicy` rejects any statement carrying a `kms:` action or a KMS resource. KMS grants belong to identities. Bucket policies stored before this check still load; evaluation skips their pure-KMS statements and warns. +- `Deny` wins, as everywhere else: a `Deny` on `arn:aws:kms:::key/payroll-*` overrides an `Allow` on `arn:aws:kms:::*`. +- The key identifier matched is the one the request names, before any alias resolution. + +## Built-in role templates + +Three canned policies ship alongside `readwrite`, `readonly`, `writeonly`, `diagnostics` and `consoleAdmin`: + +| Policy | Grants | Intended holder | +| --- | --- | --- | +| `KMSKeyAdministrator` | `kms:DescribeKey`, `kms:ListKeys`, `kms:EnableKey`, `kms:DisableKey`, `kms:RotateKey`, `kms:DeleteKey` | the team that governs key lifecycle | +| `KMSKeyUser` | `kms:GenerateDataKey`, `kms:Decrypt`, `kms:DescribeKey` | workloads that read and write SSE-KMS objects | +| `KMSAuditor` | `kms:DescribeKey`, `kms:ListKeys` | reviewers who need visibility and nothing else | + +They express separation of duties: `KMSKeyAdministrator` can disable or delete a key but can never encrypt or decrypt with it, and `KMSKeyUser` can use a key but can never change its state. + +None of the three grants `kms:Configure`, `kms:ServiceControl`, `kms:ClearCache`, `kms:Backup` or `kms:Restore`. Those act on the KMS service or on the material of every key at once, which is a cluster-administration power rather than a key-management one; they stay with `consoleAdmin`. Key creation currently shares `kms:Configure` with backend configuration, so creating keys is a `consoleAdmin` operation too. + +The templates carry no S3 or admin grants, so combine one with a data-plane policy. Policy mappings accept a comma-separated list: + +``` +PUT /rustfs/admin/v3/set-user-or-group-policy?policyName=readwrite,KMSKeyUser&userOrGroup=app-writer&isGroup=false +``` + +They are also unscoped (`arn:aws:kms:::*`) so they behave like the other canned policies. For least privilege, copy one and narrow it per workload: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": ["arn:aws:s3:::reports", "arn:aws:s3:::reports/*"] + }, + { + "Effect": "Allow", + "Action": ["kms:GenerateDataKey", "kms:Decrypt", "kms:DescribeKey"], + "Resource": ["arn:aws:kms:::key/reports-*"] + } + ] +} +``` + +## Enforcement planes + +### Admin endpoints + +Every `/rustfs/admin/v3/kms/...` endpoint that names a key authorizes against that key. The identifier is taken from the request body's `key_id`, falling back to the `keyId` query parameter. An endpoint that names no key (for example `GET /rustfs/admin/v3/kms/keys`) matches any KMS resource in the policy, as before. + +This plane is always on. It needs no migration: a policy that never mentioned a KMS resource keeps matching every key. + +### SSE-KMS data path + +An SSE-KMS write additionally requires `kms:GenerateDataKey`, and an SSE-KMS read additionally requires `kms:Decrypt`, both evaluated as the requesting identity against the key the request resolves to. This closes the confused-deputy gap where the server called KMS with its own authority on behalf of any caller holding `s3:PutObject`. + +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. +- **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. +- **Copies are checked on both ends**: `kms:Decrypt` on the source object's key and `kms:GenerateDataKey` on the destination key. `UploadPartCopy` authorizes its source read the same way. + +## 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. + +```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. + +Recommended sequence: + +1. Leave enforcement off. Inventory which identities read and write SSE-KMS objects and under which keys — the KMS fields on S3 audit entries (`sseType`, `kmsKeyId`, `kmsOutcome`) report the key each request used. +2. Attach `KMSKeyUser`, or a narrowed copy, to those identities. This is a no-op while enforcement is off, so it can be rolled out ahead of time. +3. Enable enforcement on one node and confirm the workloads still succeed. +4. Roll it out cluster-wide. + +If a workload starts failing with `AccessDenied` on an SSE-KMS object after step 3, the identity is missing `kms:GenerateDataKey` (writes) or `kms:Decrypt` (reads) on the key named in the audit entry.