mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-09 22:45:10 +00:00
e2e6a2535a
* fix(sse): classify bare SSE-KMS writes when no KMS is available
A `aws:kms` request without a key id, on a bucket without a default key,
returned `500 InternalError` whenever no KMS service was running: the
"no KMS key available" branch exited with an untyped storage error before
the availability classification that the keyed form already received.
Route that branch through the same split: `503 ServiceUnavailable` while
a configured KMS is stopped, `400 InvalidRequest` when KMS was never
configured, and `400 InvalidRequest` naming the missing key id when a
running KMS has no default key. `CreateMultipartUpload` shares the path.
Adds a unit test for the bare form and an e2e module that stops KMS
through the admin API, runs a master-key-only node, and runs a Local KMS
without a default key; refreshes the e2e-full selection digests.
(cherry picked from commit c3259dadc3d603a9185a5b0ad9f83dfb884e61c8)
* fix(sse): keep KMS error classes on the encrypted read path
GetObject, CopyObject and UploadPartCopy on an SSE-KMS object whose key
no longer exists answered `500 InternalError` ("KMS key not found") while
PutObject under the same key already answered `400 KMS.NotFoundException`.
The read path carries its classification through ecstore's
`EncryptionResolutionErrorKind`, which had no kind for a missing key, a
denied KMS grant or a missing backend capability, so all three folded
onto `DecryptionFailed` and the S3 layer reported an internal fault.
Add `KeyNotFound`, `AccessDenied` and `NotImplemented` kinds, map them on
both sides of the boundary, and give an envelope the configured backend
cannot unwrap a diagnosable message while keeping its `500`.
Unit tests cover the kind round trip and the reader wrapping; a new e2e
test deletes a key immediately and checks GET/Copy return 400 with
`KMS.NotFoundException` while HEAD stays 200. The e2e-full selection
digests are refreshed from the current listing (the previous digests
predated the delete-authorization tests) and the e2e `create_default_key`
helper is updated to the accepted `EncryptDecrypt` spelling.
(cherry picked from commit 2523a9814e97caea318d4ff1a51bef3a4d4445b2)
* fix(kms): classify key-management errors on the admin routes
`POST /kms/keys`, the legacy `create-key` alias and `generate-data-key`
reported every backend refusal as `500`: a blank key name (which each
backend failed on differently, the Local backend by writing a key file
with an empty stem), a name already taken, an unknown key, a disabled key
and a capability the backend lacks. `delete` and the lifecycle routes
already classified the same errors.
Refuse a blank or whitespace name in `KmsManager::create_key` before any
backend sees it, and share one `KmsError` to status mapping across
create, delete and generate-data-key (400 for validation and key state,
404 for an unknown key, 409 for a taken name, 501 for a missing
capability, 500 only for damaged material). The XML-error routes carry
the same status explicitly since s3s derives none for a custom code.
The read-only Static backend now reports create, delete and
cancel-deletion as `UnsupportedCapability`, matching its rotate and
enable/disable answers, so the admin API returns 501 for all of them.
(cherry picked from commit e33cac5493c4d9d6662e0d2980b58ba2b24a6d1b)
* fix(sse): stop SSE-S3 responses from naming the wrapping KMS key
`x-amz-server-side-encryption-aws-kms-key-id` is defined for `aws:kms`
objects only, but PutObject, CopyObject, CreateMultipartUpload and
GetObject returned it for `AES256` objects too, carrying the KMS key that
wraps the SSE-S3 data key (the service default, or the literal `default`
on a node without KMS). The write paths copied `kms_key_id` from the
encryption material unconditionally, and the single-decrypt GET
classification did the same after resolving the key for authorization.
Add `EncryptionMaterial::response_kms_key_id`, which yields the id only
for SSE-KMS, use it at the four write-response sites, and gate the GET
classification the same way. CompleteMultipartUpload and HeadObject
already omitted the header.
Unit tests pin both directions; a new e2e test covers Put/Get/Head/Copy
and CreateMultipartUpload for AES256 with an aws:kms control. The
e2e-full selection digests are refreshed from the current listing.
(cherry picked from commit 29d793a63352b0b60fd53c565e80fdbede8964bb)
* fix(s3): validate PutBucketEncryption rules before storing them
A default-encryption rule naming an unknown `SSEAlgorithm` (for example
`AES128`), a rule without `ApplyServerSideEncryptionByDefault`, an empty
rule list, or a `KMSMasterKeyID` on an `AES256` rule was stored as
written: the only algorithm check on the route decided whether to fill
in the default KMS key. `GetBucketEncryption` then advertised that
configuration while the write path encrypted header-less writes under
its `AES256` fallback, so the bucket's declared and actual schemes
disagreed. Two comments claimed the route already refused unknown
algorithms.
Validate the configuration before any of it is applied: `MalformedXML`
for a malformed rule set or unknown algorithm, `InvalidArgument` for a
key id on a non-KMS rule, and nothing stored on refusal. Correct the two
comments to describe when the AES256 fallback is still reachable.
Unit tests cover every refusal and the accepted shapes; an e2e test
checks the refusals leave the previous configuration in place. The
e2e-full selection digests are refreshed from the current listing.
(cherry picked from commit 29e4486dce41197ed93f5253cdbabc57d27a4ddb)
* test(e2e): refresh e2e-full selection for the combined KMS/SSE fixes
* test: align two unit tests with the new KMS and bucket-encryption contracts
`scheduled_deletion_carries_a_deadline_and_can_be_cancelled` still
expects the state error (`InvalidOperation`) for cancelling a key that
is not pending deletion; only the Static backend's mutations moved to
`UnsupportedCapability`. The uninitialized-store PutBucketEncryption
test now sends a well-formed AES256 rule so it reaches the store lookup
instead of the new configuration validation.
721 lines
28 KiB
Rust
721 lines
28 KiB
Rust
// 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.
|
||
|
||
//! Black-box behavior: master key lifecycle through `KmsManager`.
|
||
//!
|
||
//! The state × operation matrix is the load-bearing part. RustFS deliberately
|
||
//! deviates from AWS KMS in one direction: **decryption stays available while a
|
||
//! key is Disabled or PendingDeletion**, because refusing it would make every
|
||
//! object encrypted under that key unreadable the instant an operator disables
|
||
//! it. The rest of the matrix is:
|
||
//!
|
||
//! | state | encrypt / generate DEK | enable / disable | schedule deletion | cancel deletion | decrypt |
|
||
//! |-----------------|------------------------|------------------|-------------------|-----------------|---------|
|
||
//! | Enabled | allowed | allowed | allowed | rejected | allowed |
|
||
//! | Disabled | rejected | allowed | allowed | rejected | allowed |
|
||
//! | PendingDeletion | rejected | rejected | rejected | allowed | allowed |
|
||
//!
|
||
//! `crates/kms/src/backends/contract_tests.rs` pins the same matrix at the
|
||
//! backend trait; this file pins it one layer up, where the metadata cache and
|
||
//! the manager's invalidation logic also participate — a cache that served a
|
||
//! stale `Enabled` snapshot would break the gate without the backend noticing.
|
||
//!
|
||
//! Not covered here on purpose: tag / description mutation. Those types are
|
||
//! re-exported by this crate but their only entry point lives in the admin
|
||
//! handlers, outside this crate's public surface.
|
||
|
||
mod common;
|
||
|
||
use common::{
|
||
BackendCase, BackendKind, TestKms, assert_invalid_operation, assert_key_already_exists, assert_key_not_found,
|
||
assert_unsupported_capability, ctx, for_each_backend, without_probe_key,
|
||
};
|
||
use rustfs_kms::{
|
||
CancelKeyDeletionRequest, CreateKeyRequest, DecryptRequest, DeleteKeyRequest, DescribeKeyRequest, EncryptRequest,
|
||
GenerateDataKeyRequest, KeySpec, KeyState, KeyStatus, KeyUsage, KmsError, KmsManager, ListKeysRequest,
|
||
};
|
||
|
||
async fn describe_state(kms: &KmsManager, key_id: &str) -> KeyState {
|
||
kms.describe_key(DescribeKeyRequest {
|
||
key_id: key_id.to_string(),
|
||
})
|
||
.await
|
||
.expect("describe should succeed")
|
||
.key_metadata
|
||
.key_state
|
||
}
|
||
|
||
fn generate_request(key_id: &str) -> GenerateDataKeyRequest {
|
||
GenerateDataKeyRequest {
|
||
key_id: key_id.to_string(),
|
||
key_spec: KeySpec::Aes256,
|
||
encryption_context: ctx(&[("bucket", "keys-behavior")]),
|
||
}
|
||
}
|
||
|
||
fn encrypt_request(key_id: &str) -> EncryptRequest {
|
||
EncryptRequest {
|
||
key_id: key_id.to_string(),
|
||
plaintext: b"state-gated plaintext".to_vec(),
|
||
encryption_context: ctx(&[("bucket", "keys-behavior")]),
|
||
grant_tokens: Vec::new(),
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn created_key_is_enabled_and_fully_described() {
|
||
let kms = TestKms::local().await;
|
||
let manager = kms.kms().await;
|
||
|
||
let created = manager
|
||
.create_key(CreateKeyRequest {
|
||
key_name: Some("described-key".to_string()),
|
||
key_usage: KeyUsage::EncryptDecrypt,
|
||
description: Some("a described key".to_string()),
|
||
..Default::default()
|
||
})
|
||
.await
|
||
.expect("create should succeed");
|
||
|
||
assert_eq!(created.key_id, "described-key", "an explicit key name becomes the key id");
|
||
assert_eq!(created.key_metadata.key_id, created.key_id, "metadata must agree with the id");
|
||
assert_eq!(created.key_metadata.key_state, KeyState::Enabled, "a new key is immediately usable");
|
||
assert_eq!(created.key_metadata.key_usage, KeyUsage::EncryptDecrypt);
|
||
assert!(created.key_metadata.deletion_date.is_none(), "a new key has no deletion deadline");
|
||
|
||
let described = manager
|
||
.describe_key(DescribeKeyRequest {
|
||
key_id: created.key_id.clone(),
|
||
})
|
||
.await
|
||
.expect("describe should succeed")
|
||
.key_metadata;
|
||
assert_eq!(described.key_id, created.key_id);
|
||
assert_eq!(described.key_state, KeyState::Enabled);
|
||
assert_eq!(
|
||
described.description, created.key_metadata.description,
|
||
"describe must return the description supplied at creation"
|
||
);
|
||
assert_eq!(
|
||
described.creation_date, created.key_metadata.creation_date,
|
||
"the creation timestamp is stable across reads"
|
||
);
|
||
}
|
||
|
||
/// A blank name is refused by the manager before any backend sees it, so every
|
||
/// backend answers the same `ValidationError` instead of its own failure mode
|
||
/// (an empty Local key file stem, a Vault mount root, a Transit 405).
|
||
#[tokio::test]
|
||
async fn create_key_refuses_a_blank_name_before_reaching_the_backend() {
|
||
for kms in [TestKms::local().await, TestKms::static_backend().await] {
|
||
let manager = kms.kms().await;
|
||
for name in ["", " ", "\t\n"] {
|
||
let result = manager
|
||
.create_key(CreateKeyRequest {
|
||
key_name: Some(name.to_string()),
|
||
..Default::default()
|
||
})
|
||
.await;
|
||
assert!(
|
||
matches!(result, Err(KmsError::ValidationError { .. })),
|
||
"{:?} with key_name {name:?} must be a ValidationError, got {result:?}",
|
||
kms.config().backend
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn auto_generated_key_ids_are_unique() {
|
||
let kms = TestKms::local().await;
|
||
let manager = kms.kms().await;
|
||
|
||
let first = manager
|
||
.create_key(CreateKeyRequest::default())
|
||
.await
|
||
.expect("first auto-named key");
|
||
let second = manager
|
||
.create_key(CreateKeyRequest::default())
|
||
.await
|
||
.expect("second auto-named key");
|
||
|
||
assert!(!first.key_id.is_empty(), "an auto-generated key id must not be empty");
|
||
assert_ne!(first.key_id, second.key_id, "auto-generated key ids must not collide");
|
||
for created in [&first, &second] {
|
||
assert_eq!(
|
||
describe_state(&manager, &created.key_id).await,
|
||
KeyState::Enabled,
|
||
"auto-named keys are Enabled like named ones"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn duplicate_key_name_is_rejected_without_disturbing_the_original() {
|
||
let kms = TestKms::local().await;
|
||
let manager = kms.kms().await;
|
||
kms.create_key("duplicate-me").await;
|
||
|
||
assert_key_already_exists(
|
||
manager
|
||
.create_key(CreateKeyRequest {
|
||
key_name: Some("duplicate-me".to_string()),
|
||
description: Some("an impostor".to_string()),
|
||
..Default::default()
|
||
})
|
||
.await,
|
||
"duplicate-me",
|
||
);
|
||
|
||
// The rejected create must not have overwritten the original's material:
|
||
// a DEK generated before the conflict still decrypts afterwards.
|
||
let context = ctx(&[("bucket", "duplicate")]);
|
||
let dek = manager
|
||
.generate_data_key(GenerateDataKeyRequest {
|
||
key_id: "duplicate-me".to_string(),
|
||
key_spec: KeySpec::Aes256,
|
||
encryption_context: context.clone(),
|
||
})
|
||
.await
|
||
.expect("data key generation should still work");
|
||
let decrypted = manager
|
||
.decrypt(DecryptRequest {
|
||
ciphertext: dek.ciphertext_blob,
|
||
encryption_context: context,
|
||
grant_tokens: Vec::new(),
|
||
})
|
||
.await
|
||
.expect("the original key material must be intact");
|
||
assert_eq!(decrypted.plaintext, dek.plaintext_key, "round-trip after a rejected create");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn describing_an_unknown_key_reports_key_not_found() {
|
||
for_each_backend(|case: BackendCase| async move {
|
||
let manager = case.kms.kms().await;
|
||
assert_key_not_found(
|
||
manager
|
||
.describe_key(DescribeKeyRequest {
|
||
key_id: "no-such-key".to_string(),
|
||
})
|
||
.await,
|
||
"no-such-key",
|
||
);
|
||
assert_key_not_found(manager.generate_data_key(generate_request("no-such-key")).await, "no-such-key");
|
||
assert_key_not_found(manager.encrypt(encrypt_request("no-such-key")).await, "no-such-key");
|
||
})
|
||
.await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn list_keys_reports_created_keys_and_honours_filters() {
|
||
let kms = TestKms::local().await;
|
||
let manager = kms.kms().await;
|
||
for name in ["list-a", "list-b", "list-c"] {
|
||
kms.create_key(name).await;
|
||
}
|
||
|
||
let all = manager
|
||
.list_keys(ListKeysRequest::default())
|
||
.await
|
||
.expect("list should succeed");
|
||
let mut ids = without_probe_key(all.keys.iter().map(|key| key.key_id.clone()));
|
||
ids.sort();
|
||
assert_eq!(ids, vec!["list-a", "list-b", "list-c"], "every created key must be listed");
|
||
|
||
// `limit` caps the page, and a capped page must say so. A client that
|
||
// paginates by looking at `truncated` would otherwise stop after the first
|
||
// page and silently act on a partial key list — for a KMS, that means
|
||
// believing keys do not exist when they do.
|
||
let limited = manager
|
||
.list_keys(ListKeysRequest {
|
||
limit: Some(2),
|
||
..Default::default()
|
||
})
|
||
.await
|
||
.expect("limited list should succeed");
|
||
assert_eq!(limited.keys.len(), 2, "limit must cap the returned page");
|
||
assert!(
|
||
limited.truncated,
|
||
"a page that was cut short by `limit` must be reported as truncated; 3 keys exist and only 2 were returned"
|
||
);
|
||
assert!(
|
||
limited.next_marker.is_some(),
|
||
"a truncated page must carry a continuation marker so the caller can fetch the rest"
|
||
);
|
||
|
||
// A status filter narrows the result to keys in that state.
|
||
manager.disable_key("list-b").await.expect("disable should succeed");
|
||
let disabled = manager
|
||
.list_keys(ListKeysRequest {
|
||
status_filter: Some(KeyStatus::Disabled),
|
||
..Default::default()
|
||
})
|
||
.await
|
||
.expect("filtered list should succeed");
|
||
assert_eq!(
|
||
disabled.keys.iter().map(|k| k.key_id.as_str()).collect::<Vec<_>>(),
|
||
vec!["list-b"],
|
||
"only the disabled key matches the Disabled filter"
|
||
);
|
||
|
||
let active = manager
|
||
.list_keys(ListKeysRequest {
|
||
status_filter: Some(KeyStatus::Active),
|
||
..Default::default()
|
||
})
|
||
.await
|
||
.expect("filtered list should succeed");
|
||
let mut active_ids = without_probe_key(active.keys.iter().map(|k| k.key_id.clone()));
|
||
active_ids.sort();
|
||
assert_eq!(active_ids, vec!["list-a", "list-c"], "the disabled key drops out of the Active filter");
|
||
|
||
// A usage filter that matches nothing yields an empty page, not an error.
|
||
let none = manager
|
||
.list_keys(ListKeysRequest {
|
||
usage_filter: Some(KeyUsage::SignVerify),
|
||
..Default::default()
|
||
})
|
||
.await
|
||
.expect("non-matching filter should still succeed");
|
||
assert!(none.keys.is_empty(), "a filter matching nothing returns an empty page");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn disable_and_enable_round_trip_through_the_metadata_cache() {
|
||
let kms = TestKms::local().await;
|
||
let manager = kms.kms().await;
|
||
let key_id = kms.create_key("toggle-me").await;
|
||
|
||
// Warm the cache first: a stale cached Enabled snapshot would defeat the
|
||
// Disabled gate below without the backend ever being consulted.
|
||
assert_eq!(describe_state(&manager, &key_id).await, KeyState::Enabled);
|
||
|
||
manager.disable_key(&key_id).await.expect("disable should succeed");
|
||
assert_eq!(
|
||
describe_state(&manager, &key_id).await,
|
||
KeyState::Disabled,
|
||
"describe must observe the post-mutation state"
|
||
);
|
||
|
||
// Disabling again is idempotent, not an error.
|
||
manager.disable_key(&key_id).await.expect("repeat disable is idempotent");
|
||
assert_eq!(describe_state(&manager, &key_id).await, KeyState::Disabled);
|
||
|
||
manager.enable_key(&key_id).await.expect("enable should succeed");
|
||
assert_eq!(describe_state(&manager, &key_id).await, KeyState::Enabled);
|
||
manager.enable_key(&key_id).await.expect("repeat enable is idempotent");
|
||
assert_eq!(describe_state(&manager, &key_id).await, KeyState::Enabled);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn scheduled_deletion_carries_a_deadline_and_can_be_cancelled() {
|
||
let kms = TestKms::local().await;
|
||
let manager = kms.kms().await;
|
||
let key_id = kms.create_key("deletable").await;
|
||
|
||
let scheduled = manager
|
||
.delete_key(DeleteKeyRequest {
|
||
key_id: key_id.clone(),
|
||
pending_window_in_days: Some(7),
|
||
force_immediate: None,
|
||
confirm_key_id: None,
|
||
})
|
||
.await
|
||
.expect("scheduling deletion should succeed");
|
||
|
||
assert_eq!(scheduled.key_id, key_id);
|
||
assert!(
|
||
scheduled.deletion_date.is_some(),
|
||
"a scheduled deletion must report when the key will actually go away"
|
||
);
|
||
assert_eq!(scheduled.key_metadata.key_state, KeyState::PendingDeletion);
|
||
assert!(
|
||
scheduled.key_metadata.deletion_date.is_some(),
|
||
"the metadata must carry the same deadline"
|
||
);
|
||
assert_eq!(
|
||
describe_state(&manager, &key_id).await,
|
||
KeyState::PendingDeletion,
|
||
"the pending state must be visible to a subsequent describe"
|
||
);
|
||
|
||
let cancelled = manager
|
||
.cancel_key_deletion(CancelKeyDeletionRequest { key_id: key_id.clone() })
|
||
.await
|
||
.expect("cancelling should succeed");
|
||
assert_eq!(cancelled.key_id, key_id);
|
||
assert_eq!(cancelled.key_metadata.key_state, KeyState::Enabled, "cancelling restores an usable key");
|
||
assert!(cancelled.key_metadata.deletion_date.is_none(), "cancelling must clear the deadline");
|
||
assert_eq!(describe_state(&manager, &key_id).await, KeyState::Enabled);
|
||
|
||
// The key really is usable again, not merely reported as such.
|
||
manager
|
||
.generate_data_key(generate_request(&key_id))
|
||
.await
|
||
.expect("a cancelled key must accept new cryptographic work");
|
||
|
||
// Cancelling a key that is not pending deletion is a state error.
|
||
assert_invalid_operation(
|
||
manager
|
||
.cancel_key_deletion(CancelKeyDeletionRequest { key_id: key_id.clone() })
|
||
.await,
|
||
"not pending deletion",
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn deletion_pending_window_is_bounded() {
|
||
let kms = TestKms::local().await;
|
||
let manager = kms.kms().await;
|
||
|
||
for (name, days) in [("window-too-short", 6u32), ("window-too-long", 31)] {
|
||
let key_id = kms.create_key(name).await;
|
||
assert_invalid_operation(
|
||
manager
|
||
.delete_key(DeleteKeyRequest {
|
||
key_id: key_id.clone(),
|
||
pending_window_in_days: Some(days),
|
||
force_immediate: None,
|
||
confirm_key_id: None,
|
||
})
|
||
.await,
|
||
"between 7 and 30",
|
||
);
|
||
assert_eq!(
|
||
describe_state(&manager, &key_id).await,
|
||
KeyState::Enabled,
|
||
"a rejected deletion window must leave the key untouched"
|
||
);
|
||
}
|
||
|
||
// The documented bounds themselves are accepted.
|
||
for (name, days) in [("window-min", 7u32), ("window-max", 30)] {
|
||
let key_id = kms.create_key(name).await;
|
||
manager
|
||
.delete_key(DeleteKeyRequest {
|
||
key_id: key_id.clone(),
|
||
pending_window_in_days: Some(days),
|
||
force_immediate: None,
|
||
confirm_key_id: None,
|
||
})
|
||
.await
|
||
.unwrap_or_else(|error| panic!("{days} days must be accepted: {error:?}"));
|
||
assert_eq!(describe_state(&manager, &key_id).await, KeyState::PendingDeletion);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn forced_immediate_deletion_removes_the_key() {
|
||
let kms = TestKms::local_with(|config| config.allow_immediate_deletion = true).await;
|
||
let manager = kms.kms().await;
|
||
let key_id = kms.create_key("burn-now").await;
|
||
|
||
let deleted = manager
|
||
.delete_key(DeleteKeyRequest {
|
||
key_id: key_id.clone(),
|
||
pending_window_in_days: None,
|
||
force_immediate: Some(true),
|
||
confirm_key_id: Some(key_id.clone()),
|
||
})
|
||
.await
|
||
.expect("forced deletion should succeed");
|
||
assert!(deleted.deletion_date.is_none(), "an immediate deletion has no future deadline to report");
|
||
|
||
assert_key_not_found(manager.describe_key(DescribeKeyRequest { key_id: key_id.clone() }).await, &key_id);
|
||
assert_key_not_found(manager.generate_data_key(generate_request(&key_id)).await, &key_id);
|
||
assert!(
|
||
!manager
|
||
.list_keys(ListKeysRequest::default())
|
||
.await
|
||
.expect("list should succeed")
|
||
.keys
|
||
.iter()
|
||
.any(|key| key.key_id == key_id),
|
||
"a physically deleted key must disappear from listings"
|
||
);
|
||
|
||
// The name is free again, and the replacement is a genuinely new key.
|
||
let recreated = kms.create_key(&key_id).await;
|
||
assert_eq!(describe_state(&manager, &recreated).await, KeyState::Enabled);
|
||
}
|
||
|
||
/// The full state × operation matrix, run against every offline backend.
|
||
///
|
||
/// Backends that cannot reach a state (the static backend has no lifecycle at
|
||
/// all) assert the refusal instead — the capability flags are a two-way
|
||
/// contract, not just an advertisement.
|
||
#[tokio::test]
|
||
async fn key_state_gates_every_operation() {
|
||
for_each_backend(|case: BackendCase| async move {
|
||
let manager = case.kms.kms().await;
|
||
let caps = case.caps().await;
|
||
let key_id = case.key_id.clone();
|
||
|
||
// --- Enabled: everything is permitted -----------------------------
|
||
assert_eq!(
|
||
describe_state(&manager, &key_id).await,
|
||
KeyState::Enabled,
|
||
"[{}] the seeded key starts Enabled",
|
||
case.kind().name()
|
||
);
|
||
let enabled_dek = manager
|
||
.generate_data_key(generate_request(&key_id))
|
||
.await
|
||
.expect("Enabled must permit data key generation");
|
||
manager
|
||
.encrypt(encrypt_request(&key_id))
|
||
.await
|
||
.expect("Enabled must permit encryption");
|
||
|
||
// Rotation is capability-gated even in the Enabled state.
|
||
if !caps.rotate {
|
||
assert_unsupported_capability(manager.rotate_key(&key_id).await, "rotate_key");
|
||
}
|
||
|
||
if !caps.enable_disable {
|
||
assert_unsupported_capability(manager.disable_key(&key_id).await, "disable_key");
|
||
assert_unsupported_capability(manager.enable_key(&key_id).await, "enable_key");
|
||
}
|
||
if !caps.schedule_deletion {
|
||
// A read-only backend refuses deletion outright rather than
|
||
// pretending to schedule one.
|
||
assert!(
|
||
manager
|
||
.delete_key(DeleteKeyRequest {
|
||
key_id: key_id.clone(),
|
||
pending_window_in_days: Some(7),
|
||
force_immediate: None,
|
||
confirm_key_id: None,
|
||
})
|
||
.await
|
||
.is_err(),
|
||
"[{}] a backend without deletion support must refuse delete_key",
|
||
case.kind().name()
|
||
);
|
||
}
|
||
|
||
if case.kind() == BackendKind::Static {
|
||
// No further states are reachable on a read-only backend; the
|
||
// decrypt-still-works half of the matrix is checked below instead.
|
||
let decrypted = manager
|
||
.decrypt(DecryptRequest {
|
||
ciphertext: enabled_dek.ciphertext_blob.clone(),
|
||
encryption_context: ctx(&[("bucket", "keys-behavior")]),
|
||
grant_tokens: Vec::new(),
|
||
})
|
||
.await
|
||
.expect("static backend must decrypt its own envelope");
|
||
assert_eq!(decrypted.plaintext, enabled_dek.plaintext_key);
|
||
return;
|
||
}
|
||
|
||
// --- Disabled: no new crypto, but reads and lifecycle recovery ----
|
||
manager.disable_key(&key_id).await.expect("disable should succeed");
|
||
assert_eq!(describe_state(&manager, &key_id).await, KeyState::Disabled);
|
||
|
||
assert_invalid_operation(manager.generate_data_key(generate_request(&key_id)).await, "is disabled");
|
||
assert_invalid_operation(manager.encrypt(encrypt_request(&key_id)).await, "is disabled");
|
||
if caps.rotate {
|
||
assert_invalid_operation(manager.rotate_key(&key_id).await, "is disabled");
|
||
} else {
|
||
assert_unsupported_capability(manager.rotate_key(&key_id).await, "rotate_key");
|
||
}
|
||
|
||
// The deliberate deviation from AWS KMS: data written before the key
|
||
// was disabled must stay readable.
|
||
let decrypted = manager
|
||
.decrypt(DecryptRequest {
|
||
ciphertext: enabled_dek.ciphertext_blob.clone(),
|
||
encryption_context: ctx(&[("bucket", "keys-behavior")]),
|
||
grant_tokens: Vec::new(),
|
||
})
|
||
.await
|
||
.expect("a Disabled key must still decrypt existing ciphertext");
|
||
assert_eq!(
|
||
decrypted.plaintext, enabled_dek.plaintext_key,
|
||
"decryption under a Disabled key must return the original data key"
|
||
);
|
||
|
||
// Disabled still permits enabling, disabling, and scheduling deletion.
|
||
manager
|
||
.disable_key(&key_id)
|
||
.await
|
||
.expect("disable is idempotent while Disabled");
|
||
manager.enable_key(&key_id).await.expect("Disabled must permit re-enabling");
|
||
manager
|
||
.disable_key(&key_id)
|
||
.await
|
||
.expect("back to Disabled for the next step");
|
||
|
||
// --- PendingDeletion: only cancellation and decryption ------------
|
||
manager
|
||
.delete_key(DeleteKeyRequest {
|
||
key_id: key_id.clone(),
|
||
pending_window_in_days: Some(7),
|
||
force_immediate: None,
|
||
confirm_key_id: None,
|
||
})
|
||
.await
|
||
.expect("Disabled must permit scheduling deletion");
|
||
assert_eq!(describe_state(&manager, &key_id).await, KeyState::PendingDeletion);
|
||
|
||
assert_invalid_operation(manager.generate_data_key(generate_request(&key_id)).await, "pending deletion");
|
||
assert_invalid_operation(manager.encrypt(encrypt_request(&key_id)).await, "pending deletion");
|
||
assert_invalid_operation(manager.enable_key(&key_id).await, "pending deletion");
|
||
assert_invalid_operation(manager.disable_key(&key_id).await, "pending deletion");
|
||
assert_invalid_operation(
|
||
manager
|
||
.delete_key(DeleteKeyRequest {
|
||
key_id: key_id.clone(),
|
||
pending_window_in_days: Some(7),
|
||
force_immediate: None,
|
||
confirm_key_id: None,
|
||
})
|
||
.await,
|
||
"pending deletion",
|
||
);
|
||
if caps.rotate {
|
||
assert_invalid_operation(manager.rotate_key(&key_id).await, "pending deletion");
|
||
} else {
|
||
assert_unsupported_capability(manager.rotate_key(&key_id).await, "rotate_key");
|
||
}
|
||
|
||
let decrypted = manager
|
||
.decrypt(DecryptRequest {
|
||
ciphertext: enabled_dek.ciphertext_blob.clone(),
|
||
encryption_context: ctx(&[("bucket", "keys-behavior")]),
|
||
grant_tokens: Vec::new(),
|
||
})
|
||
.await
|
||
.expect("a PendingDeletion key must still decrypt existing ciphertext");
|
||
assert_eq!(decrypted.plaintext, enabled_dek.plaintext_key);
|
||
|
||
// Cancellation is the one way out, and it restores full capability.
|
||
manager
|
||
.cancel_key_deletion(CancelKeyDeletionRequest { key_id: key_id.clone() })
|
||
.await
|
||
.expect("PendingDeletion must permit cancellation");
|
||
assert_eq!(describe_state(&manager, &key_id).await, KeyState::Enabled);
|
||
manager
|
||
.generate_data_key(generate_request(&key_id))
|
||
.await
|
||
.expect("a cancelled key is fully usable again");
|
||
})
|
||
.await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn static_backend_refuses_every_lifecycle_mutation() {
|
||
let kms = TestKms::static_backend().await;
|
||
let manager = kms.kms().await;
|
||
let caps = kms.capabilities().await;
|
||
|
||
// The capability report is the contract; assert it explicitly so a backend
|
||
// that silently gains a capability has to update this test.
|
||
assert!(caps.encrypt && caps.decrypt && caps.generate_data_key, "static must do crypto");
|
||
assert!(
|
||
!caps.rotate && !caps.enable_disable && !caps.schedule_deletion && !caps.versioning && !caps.physical_delete,
|
||
"static must advertise no lifecycle capability: {caps:?}"
|
||
);
|
||
|
||
assert_unsupported_capability(
|
||
manager
|
||
.create_key(CreateKeyRequest {
|
||
key_name: Some("another-key".to_string()),
|
||
..Default::default()
|
||
})
|
||
.await,
|
||
"create_key",
|
||
);
|
||
// Re-creating the configured key is a conflict, not a generic refusal.
|
||
assert_key_already_exists(
|
||
manager
|
||
.create_key(CreateKeyRequest {
|
||
key_name: Some(kms.config().static_config().expect("static config").key_id.clone()),
|
||
..Default::default()
|
||
})
|
||
.await,
|
||
&kms.config().static_config().expect("static config").key_id,
|
||
);
|
||
|
||
let key_id = kms.config().static_config().expect("static config").key_id.clone();
|
||
assert_unsupported_capability(
|
||
manager
|
||
.delete_key(DeleteKeyRequest {
|
||
key_id: key_id.clone(),
|
||
pending_window_in_days: Some(7),
|
||
force_immediate: None,
|
||
confirm_key_id: None,
|
||
})
|
||
.await,
|
||
"delete_key",
|
||
);
|
||
assert_unsupported_capability(
|
||
manager
|
||
.cancel_key_deletion(CancelKeyDeletionRequest { key_id: key_id.clone() })
|
||
.await,
|
||
"cancel_key_deletion",
|
||
);
|
||
assert_unsupported_capability(manager.enable_key(&key_id).await, "enable_key");
|
||
assert_unsupported_capability(manager.disable_key(&key_id).await, "disable_key");
|
||
assert_unsupported_capability(manager.rotate_key(&key_id).await, "rotate_key");
|
||
|
||
// Operations aimed at any other key id are "not found", not "read-only":
|
||
// the distinction matters to the admin API's status mapping.
|
||
assert_key_not_found(
|
||
manager
|
||
.delete_key(DeleteKeyRequest {
|
||
key_id: "other".to_string(),
|
||
pending_window_in_days: Some(7),
|
||
force_immediate: None,
|
||
confirm_key_id: None,
|
||
})
|
||
.await,
|
||
"other",
|
||
);
|
||
assert_key_not_found(
|
||
manager
|
||
.cancel_key_deletion(CancelKeyDeletionRequest {
|
||
key_id: "other".to_string(),
|
||
})
|
||
.await,
|
||
"other",
|
||
);
|
||
assert_key_not_found(
|
||
manager
|
||
.describe_key(DescribeKeyRequest {
|
||
key_id: "other".to_string(),
|
||
})
|
||
.await,
|
||
"other",
|
||
);
|
||
|
||
// Despite refusing every mutation, it must still do its actual job.
|
||
let dek = manager
|
||
.generate_data_key(generate_request(&key_id))
|
||
.await
|
||
.expect("static backend must generate data keys");
|
||
assert_eq!(dek.plaintext_key.len(), 32, "AES-256 data key is 32 bytes");
|
||
let listed = manager
|
||
.list_keys(ListKeysRequest::default())
|
||
.await
|
||
.expect("list should succeed");
|
||
assert_eq!(
|
||
listed.keys.iter().map(|k| k.key_id.as_str()).collect::<Vec<_>>(),
|
||
vec![key_id.as_str()],
|
||
"the static backend lists exactly its one configured key"
|
||
);
|
||
}
|