feat(admin): apply the requested key listing filters (#5608)

This commit is contained in:
Zhengchao An
2026-08-02 14:03:57 +08:00
committed by GitHub
parent bd834297da
commit 2bf2ce1ff2
4 changed files with 310 additions and 24 deletions
+70
View File
@@ -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() {
+12 -1
View File
@@ -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
+54 -7
View File
@@ -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;