mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-12 08:06:54 +00:00
feat(admin): apply the requested key listing filters (#5608)
This commit is contained in:
@@ -3298,6 +3298,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn filtered_page_request(limit: u32, marker: Option<&str>, status: KeyStatus) -> ListKeysRequest {
|
||||
ListKeysRequest {
|
||||
status_filter: Some(status),
|
||||
..page_request(limit, marker)
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_keys(client: &LocalKmsClient, key_ids: &[String]) {
|
||||
for key_id in key_ids {
|
||||
client
|
||||
@@ -3398,6 +3405,69 @@ mod tests {
|
||||
assert!(response.next_marker.is_none());
|
||||
}
|
||||
|
||||
/// A filter narrows a page after it has been cut, so a page can come back
|
||||
/// empty while keys still remain. The traversal has to continue on
|
||||
/// `truncated` rather than on a short page, and the cursor has to advance
|
||||
/// across the keys the filter removed — otherwise a filtered listing ends
|
||||
/// at the first run of non-matching keys and reports a partial key set as
|
||||
/// the whole one.
|
||||
#[tokio::test]
|
||||
async fn filtered_paging_crosses_a_page_that_matches_nothing() {
|
||||
let (client, _temp_dir) = create_test_client().await;
|
||||
let key_ids: Vec<String> = (0..12).map(|index| format!("filter-key-{index:02}")).collect();
|
||||
create_keys(&client, &key_ids).await;
|
||||
// A page worth of adjacent keys is excluded, so one page of the
|
||||
// traversal matches nothing at all.
|
||||
for key_id in &key_ids[3..6] {
|
||||
client.disable_key(key_id, None).await.expect("key should be disabled");
|
||||
}
|
||||
let still_active: Vec<String> = key_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| !(3..6).contains(index))
|
||||
.map(|(_, key_id)| key_id.clone())
|
||||
.collect();
|
||||
|
||||
let mut seen = Vec::new();
|
||||
let mut pages_without_a_match = 0;
|
||||
let mut marker: Option<String> = None;
|
||||
// Bounded so a listing that cannot advance fails the assertions below
|
||||
// instead of hanging the test run.
|
||||
for _ in 0..key_ids.len() + 1 {
|
||||
let response = client
|
||||
.list_keys(&filtered_page_request(3, marker.as_deref(), KeyStatus::Active), None)
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert!(response.keys.len() <= 3, "a page must not exceed the requested limit");
|
||||
assert!(
|
||||
response.keys.iter().all(|key| key.status == KeyStatus::Active),
|
||||
"a filtered page must carry matches only"
|
||||
);
|
||||
if response.keys.is_empty() {
|
||||
pages_without_a_match += 1;
|
||||
}
|
||||
seen.extend(response.keys.iter().map(|key| key.key_id.clone()));
|
||||
if !response.truncated {
|
||||
assert!(response.next_marker.is_none(), "a final page must not offer a cursor");
|
||||
break;
|
||||
}
|
||||
marker = Some(response.next_marker.expect("a truncated page must offer a cursor"));
|
||||
}
|
||||
|
||||
assert_eq!(seen, still_active, "filtered paging must visit every match exactly once");
|
||||
assert_eq!(pages_without_a_match, 1, "the excluded run must produce a page with no match");
|
||||
|
||||
// The keys the first filter excluded are exactly the ones the opposite
|
||||
// filter lists, so nothing fell out of the key set on the way.
|
||||
let response = client
|
||||
.list_keys(&filtered_page_request(key_ids.len() as u32, None, KeyStatus::Disabled), None)
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
let listed: Vec<&str> = response.keys.iter().map(|key| key.key_id.as_str()).collect();
|
||||
assert_eq!(listed, key_ids[3..6].iter().map(String::as_str).collect::<Vec<_>>());
|
||||
assert!(!response.truncated, "a page covering the whole key set is complete");
|
||||
}
|
||||
|
||||
/// A limit past the end of the key set returns everything, once.
|
||||
#[tokio::test]
|
||||
async fn list_keys_limit_beyond_the_key_set_returns_one_complete_page() {
|
||||
|
||||
@@ -267,7 +267,18 @@ pub trait KmsBackend: Send + Sync {
|
||||
/// Describe a key
|
||||
async fn describe_key(&self, request: DescribeKeyRequest) -> Result<DescribeKeyResponse>;
|
||||
|
||||
/// List keys
|
||||
/// List keys.
|
||||
///
|
||||
/// `status_filter` and `usage_filter` narrow a page after it has been cut,
|
||||
/// so a filtered page can be shorter than the requested limit — and even
|
||||
/// empty — while keys remain: a caller must page until `truncated` is false
|
||||
/// rather than until a page comes back short. Every backend applies both
|
||||
/// filters; a backend that cannot answer a filter must fail rather than
|
||||
/// return the unfiltered set.
|
||||
///
|
||||
/// Backends that slice their own key set do so with [`paginate_keys`],
|
||||
/// which fixes the ordering and the marker semantics; a backend paging
|
||||
/// through a remote API passes that API's own cursor through instead.
|
||||
async fn list_keys(&self, request: ListKeysRequest) -> Result<ListKeysResponse>;
|
||||
|
||||
/// Delete a key
|
||||
|
||||
@@ -263,7 +263,8 @@ impl StaticKmsBackend {
|
||||
})
|
||||
}
|
||||
|
||||
/// List the single configured key, honouring the pagination marker.
|
||||
/// List the single configured key, honouring the pagination marker and the
|
||||
/// status and usage filters.
|
||||
pub(crate) fn list_configured_key(&self, request: &ListKeysRequest) -> Result<ListKeysResponse> {
|
||||
// A caller asking for no keys gets none, even from a backend whose
|
||||
// whole key set is one key.
|
||||
@@ -285,15 +286,25 @@ impl StaticKmsBackend {
|
||||
created_by: None,
|
||||
};
|
||||
|
||||
// Apply prefix filter if provided
|
||||
// The marker is an exclusive lower bound on the identifier, as it is
|
||||
// for every other backend.
|
||||
if let Some(ref marker) = request.marker
|
||||
&& self.key_id <= *marker
|
||||
{
|
||||
return Ok(ListKeysResponse {
|
||||
keys: vec![],
|
||||
next_marker: None,
|
||||
truncated: false,
|
||||
});
|
||||
return Ok(empty_key_page());
|
||||
}
|
||||
|
||||
// The configured key is filtered like any other: a caller narrowing the
|
||||
// listing to disabled or signing keys must get an empty page rather
|
||||
// than this active encryption key, which it would otherwise have to
|
||||
// recognise as a non-match on its own.
|
||||
if request
|
||||
.status_filter
|
||||
.as_ref()
|
||||
.is_some_and(|status| status != &key_info.status)
|
||||
|| request.usage_filter.as_ref().is_some_and(|usage| usage != &key_info.usage)
|
||||
{
|
||||
return Ok(empty_key_page());
|
||||
}
|
||||
|
||||
Ok(ListKeysResponse {
|
||||
@@ -683,6 +694,42 @@ mod tests {
|
||||
assert!(response.next_marker.is_none());
|
||||
}
|
||||
|
||||
/// A filter that excludes the configured key must empty the page. Handing
|
||||
/// the key back regardless would answer "list the disabled keys" with an
|
||||
/// active one, and the response says nothing about the filter having been
|
||||
/// dropped.
|
||||
#[tokio::test]
|
||||
async fn a_filter_the_configured_key_does_not_match_empties_the_page() {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
|
||||
for request in [
|
||||
ListKeysRequest {
|
||||
status_filter: Some(KeyStatus::Disabled),
|
||||
..Default::default()
|
||||
},
|
||||
ListKeysRequest {
|
||||
usage_filter: Some(KeyUsage::SignVerify),
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
let response = backend.list_configured_key(&request).expect("a filtered list must succeed");
|
||||
assert!(response.keys.is_empty(), "excluded key was listed for {request:?}");
|
||||
assert!(!response.truncated);
|
||||
assert!(response.next_marker.is_none());
|
||||
}
|
||||
|
||||
// The filters the key does match still list it.
|
||||
let response = backend
|
||||
.list_configured_key(&ListKeysRequest {
|
||||
status_filter: Some(KeyStatus::Active),
|
||||
usage_filter: Some(KeyUsage::EncryptDecrypt),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("a matching list must succeed");
|
||||
assert_eq!(response.keys.len(), 1);
|
||||
assert_eq!(response.keys[0].key_id, key_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_mutations_are_unsupported_at_the_product_surface() {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
|
||||
Reference in New Issue
Block a user