Files
rustfs/crates/kms/tests/behavior_keys.rs
T
唐小鸭 e2e6a2535a fix(kms): classify KMS/SSE error contracts and SSE-S3 headers (#7697)
* 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.
2026-09-12 23:48:10 +08:00

721 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
);
}