From 91172d023422a4f2b4c8e2b97b8583b57e2f3f24 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha <131262146+nikhilsinhaparseable@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:41:02 +0530 Subject: [PATCH 1/9] add query param to list api keys (#1833) support for `?priviledge=reader|ingestor|admin|writer|editor` eg. GET /api/prism/v1/apikeys?priviledge=reader returns all api keys with reader priviledge (cherry picked from commit d1f478b97e8144ce1433ef74b2bee014a1a6c1e8) --- src/handlers/http/apikeys.rs | 56 ++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index fc2320efb..7bfe8fc67 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use actix_web::{HttpRequest, HttpResponse, Responder, web}; use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, de}; use ulid::Ulid; use crate::{ @@ -15,6 +15,7 @@ use crate::{ rbac::{ Users, map::{roles, users}, + role::model::{DefaultPrivilege, Role}, user::{User, UserType}, }, utils::{get_user_and_tenant_from_request, is_admin}, @@ -100,6 +101,39 @@ pub struct ValidateApiKeyRequest { api_key: String, } +#[derive(Debug, Deserialize)] +pub struct ListApiKeysQuery { + #[serde(default, deserialize_with = "deserialize_api_key_privilege")] + priviledge: Option, +} + +fn deserialize_api_key_privilege<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer)? + .map(|privilege| match privilege.as_str() { + "reader" => Ok(DefaultPrivilege::Reader { resource: None }), + "ingestor" => Ok(DefaultPrivilege::Ingestor { resource: None }), + "admin" => Ok(DefaultPrivilege::Admin), + "writer" => Ok(DefaultPrivilege::Writer { resource: None }), + "editor" => Ok(DefaultPrivilege::Editor), + _ => Err(de::Error::unknown_variant( + &privilege, + &["reader", "ingestor", "admin", "writer", "editor"], + )), + }) + .transpose() +} + +fn role_has_privilege(role: &Role, privilege: &DefaultPrivilege) -> bool { + role.privileges() + .iter() + .any(|candidate| std::mem::discriminant(candidate) == std::mem::discriminant(privilege)) +} + impl ApiKeyListEntry { fn from_user(user: &User) -> Option { let api_key = user.as_api_key()?; @@ -295,11 +329,27 @@ pub async fn delete_api_key( /// GET /api/prism/v1/apikeys /// -/// List all API keys (masked). Only admins can list keys. -pub async fn list_api_keys(req: HttpRequest) -> Result { +/// List all API keys (masked). Only admins can list keys. Optionally filter by +/// role privilege with `?priviledge=reader|ingestor|admin|writer|editor`. +pub async fn list_api_keys( + req: HttpRequest, + web::Query(query): web::Query, +) -> Result { let (_, tenant_id) = verify_admin(&req)?; + let tenant = tenant_or_default(&tenant_id); + let tenant_roles = roles(); let entries: Vec = collect_tenant_api_keys(&tenant_id) .iter() + .filter(|user| { + query.priviledge.as_ref().is_none_or(|privilege| { + user.roles.iter().any(|role_name| { + tenant_roles + .get(tenant) + .and_then(|roles| roles.get(role_name)) + .is_some_and(|role| role_has_privilege(role, privilege)) + }) + }) + }) .filter_map(ApiKeyListEntry::from_user) .collect(); Ok(HttpResponse::Ok().json(entries)) From 54072fed539853d08eab1adadcc55b070558a58b Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:06:06 -0500 Subject: [PATCH 2/9] Accept `privilege` spelling for API key list filter; add tests The upstream filter (d1f478b9) only accepts the misspelled `?priviledge=`. Make `privilege` the canonical field and keep `priviledge` as a serde alias so upstream clients keep working. Unknown values (or supplying both spellings) are rejected by the Query extractor with 400. Extract the per-key match into `user_has_privilege` and cover parsing (both spellings, all values, no param, invalid values) and filtering (scoped privileges, multi-privilege roles, dangling/no roles). Co-Authored-By: Claude Opus 5.5 --- src/handlers/http/apikeys.rs | 177 ++++++++++++++++++++++++++++++++--- 1 file changed, 162 insertions(+), 15 deletions(-) diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index 7bfe8fc67..ab78541e7 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use actix_web::{HttpRequest, HttpResponse, Responder, web}; use chrono::{DateTime, Utc}; @@ -101,10 +101,17 @@ pub struct ValidateApiKeyRequest { api_key: String, } +/// Query params for `GET /apikeys`. `privilege` is the canonical name; +/// `priviledge` is accepted as an alias for compatibility with upstream +/// Parseable clients, which use that spelling. #[derive(Debug, Deserialize)] pub struct ListApiKeysQuery { - #[serde(default, deserialize_with = "deserialize_api_key_privilege")] - priviledge: Option, + #[serde( + default, + alias = "priviledge", + deserialize_with = "deserialize_api_key_privilege" + )] + privilege: Option, } fn deserialize_api_key_privilege<'de, D>( @@ -134,6 +141,20 @@ fn role_has_privilege(role: &Role, privilege: &DefaultPrivilege) -> bool { .any(|candidate| std::mem::discriminant(candidate) == std::mem::discriminant(privilege)) } +/// Whether any of the user's roles in `tenant_roles` grants `privilege`. +/// Roles missing from the map never match. +fn user_has_privilege( + user: &User, + tenant_roles: Option<&HashMap>, + privilege: &DefaultPrivilege, +) -> bool { + user.roles.iter().any(|role_name| { + tenant_roles + .and_then(|roles| roles.get(role_name)) + .is_some_and(|role| role_has_privilege(role, privilege)) + }) +} + impl ApiKeyListEntry { fn from_user(user: &User) -> Option { let api_key = user.as_api_key()?; @@ -330,25 +351,23 @@ pub async fn delete_api_key( /// GET /api/prism/v1/apikeys /// /// List all API keys (masked). Only admins can list keys. Optionally filter by -/// role privilege with `?priviledge=reader|ingestor|admin|writer|editor`. +/// role privilege with `?privilege=reader|ingestor|admin|writer|editor` +/// (`?priviledge=` is accepted too). An unknown value is rejected with 400. pub async fn list_api_keys( req: HttpRequest, web::Query(query): web::Query, ) -> Result { let (_, tenant_id) = verify_admin(&req)?; - let tenant = tenant_or_default(&tenant_id); - let tenant_roles = roles(); - let entries: Vec = collect_tenant_api_keys(&tenant_id) + let keys = collect_tenant_api_keys(&tenant_id); + let all_roles = roles(); + let tenant_roles = all_roles.get(tenant_or_default(&tenant_id)); + let entries: Vec = keys .iter() .filter(|user| { - query.priviledge.as_ref().is_none_or(|privilege| { - user.roles.iter().any(|role_name| { - tenant_roles - .get(tenant) - .and_then(|roles| roles.get(role_name)) - .is_some_and(|role| role_has_privilege(role, privilege)) - }) - }) + query + .privilege + .as_ref() + .is_none_or(|privilege| user_has_privilege(user, tenant_roles, privilege)) }) .filter_map(ApiKeyListEntry::from_user) .collect(); @@ -397,6 +416,7 @@ pub async fn get_api_key( #[cfg(test)] mod tests { use super::*; + use crate::rbac::role::ParseableResourceType; #[test] fn expired_key_remains_visible_with_expiry_in_detail_and_list() { @@ -439,4 +459,131 @@ mod tests { assert!(detail.get("expiresAt").unwrap().is_null()); assert!(entry.get("expiresAt").unwrap().is_null()); } + + fn parse_query(query: &str) -> Result { + web::Query::::from_query(query) + .map(web::Query::into_inner) + .map_err(Into::into) + } + + fn key_with_roles(name: &str, roles: &[&str]) -> User { + User::new_api_key( + Ulid::new(), + format!("{name}-secret"), + name.into(), + roles.iter().map(|r| r.to_string()).collect(), + "admin".into(), + None, + ) + } + + fn filtered_names( + keys: &[User], + tenant_roles: Option<&HashMap>, + privilege: &DefaultPrivilege, + ) -> Vec { + let mut names: Vec = keys + .iter() + .filter(|user| user_has_privilege(user, tenant_roles, privilege)) + .filter_map(|user| user.as_api_key().map(|k| k.key_name.clone())) + .collect(); + names.sort(); + names + } + + #[test] + fn list_query_without_privilege_is_unfiltered() { + assert!(parse_query("").unwrap().privilege.is_none()); + } + + #[test] + fn list_query_accepts_every_privilege_under_both_spellings() { + let cases = [ + ("reader", DefaultPrivilege::Reader { resource: None }), + ("ingestor", DefaultPrivilege::Ingestor { resource: None }), + ("admin", DefaultPrivilege::Admin), + ("writer", DefaultPrivilege::Writer { resource: None }), + ("editor", DefaultPrivilege::Editor), + ]; + for (value, expected) in cases { + for param in ["privilege", "priviledge"] { + let parsed = parse_query(&format!("{param}={value}")) + .unwrap_or_else(|err| panic!("{param}={value}: {err}")) + .privilege; + assert_eq!(parsed, Some(expected.clone()), "{param}={value}"); + } + } + } + + #[test] + fn list_query_rejects_unknown_privilege_with_bad_request() { + for query in [ + "privilege=owner", + "priviledge=owner", + "privilege=Reader", + "privilege=", + "privilege=reader&priviledge=reader", + ] { + let err = parse_query(query).expect_err(query); + assert_eq!( + err.as_response_error().status_code(), + actix_web::http::StatusCode::BAD_REQUEST, + "{query}" + ); + } + } + + #[test] + fn privilege_filter_matches_keys_by_role_privileges() { + let tenant_roles: HashMap = [ + ( + "readers".to_string(), + Role::create_user_role(vec![DefaultPrivilege::Reader { + resource: Some(ParseableResourceType::Stream("logs".into())), + }]), + ), + ( + "ingest-and-read".to_string(), + Role::create_user_role(vec![ + DefaultPrivilege::Ingestor { resource: None }, + DefaultPrivilege::Reader { resource: None }, + ]), + ), + ( + "admins".to_string(), + Role::create_user_role(vec![DefaultPrivilege::Admin]), + ), + ] + .into_iter() + .collect(); + let keys = [ + key_with_roles("reader-key", &["readers"]), + key_with_roles("pipeline-key", &["ingest-and-read"]), + key_with_roles("admin-key", &["admins"]), + key_with_roles("dangling-key", &["deleted-role"]), + key_with_roles("roleless-key", &[]), + ]; + + let reader = DefaultPrivilege::Reader { resource: None }; + // Resource-scoped privileges still match the bare privilege filter. + assert_eq!( + filtered_names(&keys, Some(&tenant_roles), &reader), + ["pipeline-key", "reader-key"] + ); + assert_eq!( + filtered_names( + &keys, + Some(&tenant_roles), + &DefaultPrivilege::Ingestor { resource: None } + ), + ["pipeline-key"] + ); + assert_eq!( + filtered_names(&keys, Some(&tenant_roles), &DefaultPrivilege::Admin), + ["admin-key"] + ); + assert!(filtered_names(&keys, Some(&tenant_roles), &DefaultPrivilege::Editor).is_empty()); + // A tenant without a role map matches nothing. + assert!(filtered_names(&keys, None, &reader).is_empty()); + } } From a0b47035b04add81b55d5a9cd0b0c47f172d7a09 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:57:24 -0500 Subject: [PATCH 3/9] Match group-inherited roles in API key privilege filter Review fixes for the `?privilege=` filter on GET /apikeys: - Keys whose privilege comes from a user group now match, using the same direct + group roles that authorization grants. Groups are read before roles (documented lock order), only when a filter is given, and both guards drop before entries are built. - Move the handler's tenant lookup and filtering into `tenant_entries`, and test it against per-test tenants in the global maps: unfiltered listing, every privilege kind, group-only roles, per-tenant role resolution, and tenants without a role map. - Assert the 400 message names the rejected value. - Document that unknown parameter names are ignored and that `roles` lists direct roles only. - Make the middleware API-key tests add their admin role even when another test created ROLES first; they previously relied on winning that race. Co-Authored-By: Claude Opus 5.5 --- frontend/docs/api-contracts.md | 2 + src/handlers/http/apikeys.rs | 313 ++++++++++++++++++++++---------- src/handlers/http/middleware.rs | 15 +- 3 files changed, 231 insertions(+), 99 deletions(-) diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md index 29adb7fb3..d4a86883f 100644 --- a/frontend/docs/api-contracts.md +++ b/frontend/docs/api-contracts.md @@ -49,6 +49,8 @@ Team requests share the cookie session and `credentials: 'include'`. The adminis | `getApiKey(id, signal?)` | `GET /api/prism/v1/apikeys/{encoded id}` | Full key, retrieved only on an explicit copy action | | `deleteApiKey(id)` | `DELETE /api/prism/v1/apikeys/{encoded id}` | Checked success; backend returns `{keyId, keyName, message}` | +API key listing accepts `?privilege=reader|ingestor|admin|writer|editor` (alias `priviledge`), matching direct or group-inherited role privileges by kind (an admin key does not match `reader`). Each entry's `roles` lists direct roles only. Unknown or differently cased values, or a repeated parameter, return 400; unknown parameter names are ignored and leave the list unfiltered. + API key expiry is optional: `ttlSeconds` must be an integer from 1800 (30 minutes) through 7776000 (90 days), inclusive; invalid values return 400. Omitted or null TTL means no expiration. Responses include `expiresAt`, a UTC timestamp or null; the frontend also accepts an omitted field from older servers for legacy records. Finite lifetimes are elapsed durations from creation, independent of time zones or daylight saving changes. The creation form defaults to 90 days and offers presets, custom minutes/hours/days, and No expiration. TTL cannot be edited after creation. Expired keys remain listed and deletable; their names remain reserved until deletion. Both `X-API-KEY` and Bearer authentication return 401 once server time reaches `expiresAt`, and key validation reports false. Temporary API-key sessions, including those created for intra-cluster requests, cannot extend the deadline or refresh as OAuth sessions. Intra-cluster requests on behalf of an expired key return 401. Requests authorized before expiry may finish on the node that authorized them. Expiration changes credential validity, not its assigned roles or an administrator's authority to create other credentials. diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index ab78541e7..c74212b35 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -14,9 +14,9 @@ use crate::{ parseable::DEFAULT_TENANT, rbac::{ Users, - map::{roles, users}, + map::{read_user_groups, roles, users}, role::model::{DefaultPrivilege, Role}, - user::{User, UserType}, + user::{User, UserGroup, UserType}, }, utils::{get_user_and_tenant_from_request, is_admin}, }; @@ -103,7 +103,8 @@ pub struct ValidateApiKeyRequest { /// Query params for `GET /apikeys`. `privilege` is the canonical name; /// `priviledge` is accepted as an alias for compatibility with upstream -/// Parseable clients, which use that spelling. +/// Parseable clients, which use that spelling. Unknown parameter names, such as +/// `Privilege`, are ignored and leave the list unfiltered. #[derive(Debug, Deserialize)] pub struct ListApiKeysQuery { #[serde( @@ -141,20 +142,53 @@ fn role_has_privilege(role: &Role, privilege: &DefaultPrivilege) -> bool { .any(|candidate| std::mem::discriminant(candidate) == std::mem::discriminant(privilege)) } -/// Whether any of the user's roles in `tenant_roles` grants `privilege`. -/// Roles missing from the map never match. +/// Whether any direct or group-inherited role grants `privilege`. +/// Groups and roles missing from the tenant maps never match. fn user_has_privilege( user: &User, tenant_roles: Option<&HashMap>, + tenant_groups: Option<&HashMap>, privilege: &DefaultPrivilege, ) -> bool { - user.roles.iter().any(|role_name| { + let group_roles = user + .user_groups + .iter() + .filter_map(|group_name| tenant_groups.and_then(|groups| groups.get(group_name))) + .flat_map(|group| &group.roles); + user.roles.iter().chain(group_roles).any(|role_name| { tenant_roles .and_then(|roles| roles.get(role_name)) .is_some_and(|role| role_has_privilege(role, privilege)) }) } +/// Masked list entries for the tenant's API keys whose direct or group-inherited +/// roles grant `privilege`, or all of them when `privilege` is `None`. +fn tenant_entries( + tenant_id: &Option, + privilege: Option<&DefaultPrivilege>, +) -> Vec { + let keys = collect_tenant_api_keys(tenant_id); + let matching: Vec<&User> = match privilege { + None => keys.iter().collect(), + Some(privilege) => { + // Lock order: user groups before roles. Both guards drop at the end + // of this arm, before entries are built. + let all_groups = read_user_groups(); + let all_roles = roles(); + let tenant = tenant_or_default(tenant_id); + let (tenant_roles, tenant_groups) = (all_roles.get(tenant), all_groups.get(tenant)); + keys.iter() + .filter(|user| user_has_privilege(user, tenant_roles, tenant_groups, privilege)) + .collect() + } + }; + matching + .into_iter() + .filter_map(ApiKeyListEntry::from_user) + .collect() +} + impl ApiKeyListEntry { fn from_user(user: &User) -> Option { let api_key = user.as_api_key()?; @@ -353,24 +387,13 @@ pub async fn delete_api_key( /// List all API keys (masked). Only admins can list keys. Optionally filter by /// role privilege with `?privilege=reader|ingestor|admin|writer|editor` /// (`?priviledge=` is accepted too). An unknown value is rejected with 400. +/// Unknown parameter names are ignored and return the unfiltered list. pub async fn list_api_keys( req: HttpRequest, web::Query(query): web::Query, ) -> Result { let (_, tenant_id) = verify_admin(&req)?; - let keys = collect_tenant_api_keys(&tenant_id); - let all_roles = roles(); - let tenant_roles = all_roles.get(tenant_or_default(&tenant_id)); - let entries: Vec = keys - .iter() - .filter(|user| { - query - .privilege - .as_ref() - .is_none_or(|privilege| user_has_privilege(user, tenant_roles, privilege)) - }) - .filter_map(ApiKeyListEntry::from_user) - .collect(); + let entries = tenant_entries(&tenant_id, query.privilege.as_ref()); Ok(HttpResponse::Ok().json(entries)) } @@ -415,8 +438,15 @@ pub async fn get_api_key( #[cfg(test)] mod tests { + use parking_lot::RwLock; + use super::*; - use crate::rbac::role::ParseableResourceType; + use crate::rbac::{ + map::{ + self, ROLES, USER_GROUPS, USERS, UserGroups, mut_roles, mut_users, write_user_groups, + }, + role::ParseableResourceType, + }; #[test] fn expired_key_remains_visible_with_expiry_in_detail_and_list() { @@ -466,34 +496,11 @@ mod tests { .map_err(Into::into) } - fn key_with_roles(name: &str, roles: &[&str]) -> User { - User::new_api_key( - Ulid::new(), - format!("{name}-secret"), - name.into(), - roles.iter().map(|r| r.to_string()).collect(), - "admin".into(), - None, - ) - } - - fn filtered_names( - keys: &[User], - tenant_roles: Option<&HashMap>, - privilege: &DefaultPrivilege, - ) -> Vec { - let mut names: Vec = keys - .iter() - .filter(|user| user_has_privilege(user, tenant_roles, privilege)) - .filter_map(|user| user.as_api_key().map(|k| k.key_name.clone())) - .collect(); - names.sort(); - names - } - #[test] fn list_query_without_privilege_is_unfiltered() { - assert!(parse_query("").unwrap().privilege.is_none()); + for query in ["", "Privilege=reader", "privilage=reader", "_=123"] { + assert!(parse_query(query).unwrap().privilege.is_none(), "{query}"); + } } #[test] @@ -517,73 +524,193 @@ mod tests { #[test] fn list_query_rejects_unknown_privilege_with_bad_request() { - for query in [ - "privilege=owner", - "priviledge=owner", - "privilege=Reader", - "privilege=", - "privilege=reader&priviledge=reader", - ] { + let cases = [ + ("privilege=owner", "unknown variant `owner`"), + ("priviledge=owner", "unknown variant `owner`"), + ("privilege=Reader", "unknown variant `Reader`"), + ("privilege=superadmin", "unknown variant `superadmin`"), + ("privilege=", "unknown variant ``"), + ("privilege=reader&priviledge=reader", "duplicate field"), + ("privilege=reader&privilege=admin", "duplicate field"), + ]; + for (query, message) in cases { let err = parse_query(query).expect_err(query); assert_eq!( err.as_response_error().status_code(), actix_web::http::StatusCode::BAD_REQUEST, "{query}" ); + assert!(err.to_string().contains(message), "{query}: {err}"); } } + // `map::init` needs `PARSEABLE`, so tests create the maps directly. Each + // test uses its own tenant, so they don't see each other's keys or roles. + fn tenant(roles: Vec<(&str, Vec)>, groups: &[(&str, &str)]) -> String { + USERS.get_or_init(|| RwLock::new(map::Users::default())); + USER_GROUPS.get_or_init(|| RwLock::new(UserGroups::default())); + ROLES.get_or_init(|| RwLock::new(HashMap::new())); + let tenant = format!("apikeys-{}", Ulid::new()); + mut_roles().insert( + tenant.clone(), + roles + .into_iter() + .map(|(name, privileges)| (name.to_owned(), Role::create_user_role(privileges))) + .collect(), + ); + for (name, role) in groups { + let group = UserGroup { + name: (*name).to_owned(), + roles: HashSet::from([(*role).to_owned()]), + ..Default::default() + }; + write_user_groups().insert(group, &tenant); + } + tenant + } + + fn add_key(tenant: &str, name: &str, roles: &[&str], groups: &[&str]) { + let mut key = User::new_api_key( + Ulid::new(), + format!("{name}-secret"), + name.into(), + roles.iter().map(|r| r.to_string()).collect(), + "admin".into(), + Some(tenant.to_owned()), + ); + key.user_groups = groups.iter().map(|g| g.to_string()).collect(); + mut_users().insert(key); + } + + fn names(tenant: &str, privilege: Option) -> Vec { + let mut names: Vec = tenant_entries(&Some(tenant.to_owned()), privilege.as_ref()) + .into_iter() + .map(|entry| entry.key_name) + .collect(); + names.sort(); + names + } + + #[test] + fn list_without_privilege_returns_every_key_masked() { + let tenant = tenant( + vec![("readers", vec![DefaultPrivilege::Reader { resource: None }])], + &[], + ); + add_key(&tenant, "reader-key", &["readers"], &[]); + add_key(&tenant, "dangling-key", &["deleted-role"], &[]); + add_key(&tenant, "roleless-key", &[], &[]); + let entries = tenant_entries(&Some(tenant.clone()), None); + assert!(entries.iter().all(|entry| entry.api_key == "****cret")); + assert_eq!( + names(&tenant, None), + ["dangling-key", "reader-key", "roleless-key"] + ); + } + #[test] fn privilege_filter_matches_keys_by_role_privileges() { - let tenant_roles: HashMap = [ - ( - "readers".to_string(), - Role::create_user_role(vec![DefaultPrivilege::Reader { - resource: Some(ParseableResourceType::Stream("logs".into())), - }]), - ), - ( - "ingest-and-read".to_string(), - Role::create_user_role(vec![ - DefaultPrivilege::Ingestor { resource: None }, - DefaultPrivilege::Reader { resource: None }, - ]), - ), - ( - "admins".to_string(), - Role::create_user_role(vec![DefaultPrivilege::Admin]), - ), - ] - .into_iter() - .collect(); - let keys = [ - key_with_roles("reader-key", &["readers"]), - key_with_roles("pipeline-key", &["ingest-and-read"]), - key_with_roles("admin-key", &["admins"]), - key_with_roles("dangling-key", &["deleted-role"]), - key_with_roles("roleless-key", &[]), - ]; + let tenant = tenant( + vec![ + ( + "readers", + vec![DefaultPrivilege::Reader { + resource: Some(ParseableResourceType::Stream("logs".into())), + }], + ), + ( + "ingest-and-read", + vec![ + DefaultPrivilege::Ingestor { resource: None }, + DefaultPrivilege::Reader { resource: None }, + ], + ), + ("admins", vec![DefaultPrivilege::Admin]), + ("writers", vec![DefaultPrivilege::Writer { resource: None }]), + ("editors", vec![DefaultPrivilege::Editor]), + ], + &[], + ); + add_key(&tenant, "reader-key", &["readers"], &[]); + add_key(&tenant, "pipeline-key", &["ingest-and-read"], &[]); + add_key(&tenant, "admin-key", &["admins"], &[]); + add_key(&tenant, "writer-key", &["writers"], &[]); + add_key(&tenant, "editor-key", &["editors"], &[]); + add_key(&tenant, "dangling-key", &["deleted-role"], &[]); + add_key(&tenant, "roleless-key", &[], &[]); - let reader = DefaultPrivilege::Reader { resource: None }; // Resource-scoped privileges still match the bare privilege filter. assert_eq!( - filtered_names(&keys, Some(&tenant_roles), &reader), + names(&tenant, Some(DefaultPrivilege::Reader { resource: None })), ["pipeline-key", "reader-key"] ); assert_eq!( - filtered_names( - &keys, - Some(&tenant_roles), - &DefaultPrivilege::Ingestor { resource: None } - ), + names(&tenant, Some(DefaultPrivilege::Ingestor { resource: None })), ["pipeline-key"] ); + // Privileges match by kind, not hierarchy: admin keys are not readers. + assert_eq!(names(&tenant, Some(DefaultPrivilege::Admin)), ["admin-key"]); assert_eq!( - filtered_names(&keys, Some(&tenant_roles), &DefaultPrivilege::Admin), - ["admin-key"] + names(&tenant, Some(DefaultPrivilege::Writer { resource: None })), + ["writer-key"] ); - assert!(filtered_names(&keys, Some(&tenant_roles), &DefaultPrivilege::Editor).is_empty()); - // A tenant without a role map matches nothing. - assert!(filtered_names(&keys, None, &reader).is_empty()); + assert_eq!( + names(&tenant, Some(DefaultPrivilege::Editor)), + ["editor-key"] + ); + } + + #[test] + fn privilege_filter_includes_group_inherited_roles() { + let tenant = tenant( + vec![("readers", vec![DefaultPrivilege::Reader { resource: None }])], + &[ + ("readers-group", "readers"), + ("dangling-group", "deleted-role"), + ], + ); + add_key(&tenant, "group-key", &[], &["readers-group"]); + add_key(&tenant, "dangling-group-key", &[], &["dangling-group"]); + add_key(&tenant, "missing-group-key", &[], &["missing-group"]); + assert_eq!( + names(&tenant, Some(DefaultPrivilege::Reader { resource: None })), + ["group-key"] + ); + assert!(names(&tenant, Some(DefaultPrivilege::Admin)).is_empty()); + } + + #[test] + fn privilege_filter_uses_the_callers_tenant_roles() { + // Same role and group names, different privileges per tenant. + let readers = tenant( + vec![("team", vec![DefaultPrivilege::Reader { resource: None }])], + &[("team-group", "team")], + ); + let admins = tenant( + vec![("team", vec![DefaultPrivilege::Admin])], + &[("team-group", "team")], + ); + add_key(&readers, "reader-key", &["team"], &[]); + add_key(&readers, "reader-group-key", &[], &["team-group"]); + add_key(&admins, "admin-key", &["team"], &[]); + add_key(&admins, "admin-group-key", &[], &["team-group"]); + + let reader = || Some(DefaultPrivilege::Reader { resource: None }); + assert_eq!( + names(&readers, reader()), + ["reader-group-key", "reader-key"] + ); + assert!(names(&readers, Some(DefaultPrivilege::Admin)).is_empty()); + assert_eq!( + names(&admins, Some(DefaultPrivilege::Admin)), + ["admin-group-key", "admin-key"] + ); + assert!(names(&admins, reader()).is_empty()); + + // Keys in a tenant without a role map match no filter. + let unknown = format!("apikeys-{}", Ulid::new()); + add_key(&unknown, "orphan-key", &["team"], &[]); + assert_eq!(names(&unknown, None), ["orphan-key"]); + assert!(names(&unknown, reader()).is_empty()); } } diff --git a/src/handlers/http/middleware.rs b/src/handlers/http/middleware.rs index 4507a9b8d..42057756b 100644 --- a/src/handlers/http/middleware.rs +++ b/src/handlers/http/middleware.rs @@ -1123,7 +1123,8 @@ mod api_key_auth_tests { use super::*; use crate::rbac::{ map::{ - ROLES, SESSIONS, Sessions, USER_GROUPS, USERS, UserGroups, Users as UserMap, mut_users, + ROLES, SESSIONS, Sessions, USER_GROUPS, USERS, UserGroups, Users as UserMap, mut_roles, + mut_users, }, role::model::{DefaultPrivilege, Role}, }; @@ -1154,11 +1155,13 @@ mod api_key_auth_tests { USERS.get_or_init(|| RwLock::new(UserMap::default())); SESSIONS.get_or_init(|| RwLock::new(Sessions::default())); USER_GROUPS.get_or_init(|| RwLock::new(UserGroups::default())); - ROLES.get_or_init(|| { - let role = Role::create_internal_role(vec![DefaultPrivilege::Admin]); - let roles = HashMap::from([(ROLE.to_owned(), role)]); - RwLock::new(HashMap::from([(DEFAULT_TENANT.to_owned(), roles)])) - }); + // Other tests may create ROLES first, so add the role either way. + ROLES.get_or_init(|| RwLock::new(HashMap::new())); + mut_roles() + .entry(DEFAULT_TENANT.to_owned()) + .or_default() + .entry(ROLE.to_owned()) + .or_insert_with(|| Role::create_internal_role(vec![DefaultPrivilege::Admin])); CLUSTER_SECRET.get_or_init(|| { let salt = SaltString::encode_b64(b"api-key-expiry-salt").unwrap(); let hash = Argon2::default() From 1ef0a42aec1e2c1e96ffb8d411f891332347b5a5 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:33:39 -0500 Subject: [PATCH 4/9] ci: migrate all workflows off Blacksmith --- .github/actionlint.yaml | 4 -- .github/workflows/blacksmith-testbox.yml | 30 ------------ .github/workflows/build.yaml | 38 +++++++++++++-- .github/workflows/ci.yaml | 4 +- .github/workflows/coverage.yaml | 36 ++++++++++++-- .github/workflows/frontend.yaml | 4 +- .github/workflows/integration-test.yaml | 27 ++++++++--- .github/workflows/release.yml | 61 +++++++++++++++--------- README.md | 4 +- 9 files changed, 129 insertions(+), 79 deletions(-) delete mode 100644 .github/actionlint.yaml delete mode 100644 .github/workflows/blacksmith-testbox.yml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index 055e04ef6..000000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,4 +0,0 @@ -self-hosted-runner: - labels: - - blacksmith-8vcpu-ubuntu-2404 - - blacksmith-8vcpu-ubuntu-2404-arm diff --git a/.github/workflows/blacksmith-testbox.yml b/.github/workflows/blacksmith-testbox.yml deleted file mode 100644 index 2527d614d..000000000 --- a/.github/workflows/blacksmith-testbox.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Blacksmith Testbox -on: - workflow_dispatch: - inputs: - testbox_id: - type: string - description: Testbox session ID - required: true -permissions: - contents: read -jobs: - frontend: - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 60 - steps: - - name: Begin Testbox - uses: useblacksmith/begin-testbox@v2 - with: - testbox_id: ${{ inputs.testbox_id }} - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: npm - cache-dependency-path: frontend/package-lock.json - - run: npm ci && npx playwright install --with-deps chromium - working-directory: frontend - - name: Run Testbox - uses: useblacksmith/run-testbox@v2 - if: always() diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6a38f28cd..8e94a79a6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -13,15 +13,15 @@ jobs: build-default: name: Build Default ${{matrix.target}} runs-on: ${{ matrix.os }} - timeout-minutes: 90 + timeout-minutes: 180 strategy: fail-fast: false matrix: include: # Linux builds - both native, matching the release workflow - - os: blacksmith-8vcpu-ubuntu-2404 + - os: ubuntu-24.04 target: x86_64-unknown-linux-gnu - - os: blacksmith-8vcpu-ubuntu-2404-arm + - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu # macOS build - aarch64 only (M1/M2) @@ -34,6 +34,20 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Prepare Linux build resources + if: runner.os == 'Linux' + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL + echo "CARGO_BUILD_JOBS=2" >> "$GITHUB_ENV" + if sudo fallocate -l 16G /swap.build && sudo chmod 600 /swap.build \ + && sudo mkswap /swap.build && sudo swapon /swap.build; then + echo "Added 16G swap" + else + sudo rm -f /swap.build + echo "::warning title=Swap::Could not add swap; continuing without it" + fi + free -h + df -h / - name: Set up Node.js uses: actions/setup-node@v4 @@ -79,10 +93,24 @@ jobs: # Kafka build for x86_64 Linux only build-kafka: name: Build Kafka x86_64-unknown-linux-gnu - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 90 + runs-on: ubuntu-24.04 + timeout-minutes: 180 steps: - uses: actions/checkout@v4 + - name: Prepare Linux build resources + if: runner.os == 'Linux' + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL + echo "CARGO_BUILD_JOBS=2" >> "$GITHUB_ENV" + if sudo fallocate -l 16G /swap.build && sudo chmod 600 /swap.build \ + && sudo mkswap /swap.build && sudo swapon /swap.build; then + echo "Added 16G swap" + else + sudo rm -f /swap.build + echo "::warning title=Swap::Could not add swap; continuing without it" + fi + free -h + df -h / - name: Set up Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 11c9df89b..62bc3729c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,7 +23,7 @@ concurrency: jobs: changes: - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: frontend: ${{ steps.paths.outputs.frontend }} @@ -94,7 +94,7 @@ jobs: name: CI passed if: always() needs: [changes, frontend, rust, integration] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Check required results diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 2fd4cd490..100e30c19 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -13,10 +13,24 @@ env: jobs: clippy: - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 45 + runs-on: ubuntu-24.04 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 + - name: Prepare Linux build resources + if: runner.os == 'Linux' + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL + echo "CARGO_BUILD_JOBS=2" >> "$GITHUB_ENV" + if sudo fallocate -l 16G /swap.build && sudo chmod 600 /swap.build \ + && sudo mkswap /swap.build && sudo swapon /swap.build; then + echo "Added 16G swap" + else + sudo rm -f /swap.build + echo "::warning title=Swap::Could not add swap; continuing without it" + fi + free -h + df -h / - uses: ./.github/actions/setup-rust-ci with: cache-key: rust-clippy @@ -27,10 +41,24 @@ jobs: run: cargo hack clippy --verbose --each-feature --no-dev-deps -- -D warnings tests: - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 45 + runs-on: ubuntu-24.04 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 + - name: Prepare Linux build resources + if: runner.os == 'Linux' + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL + echo "CARGO_BUILD_JOBS=2" >> "$GITHUB_ENV" + if sudo fallocate -l 16G /swap.build && sudo chmod 600 /swap.build \ + && sudo mkswap /swap.build && sudo swapon /swap.build; then + echo "Added 16G swap" + else + sudo rm -f /swap.build + echo "::warning title=Swap::Could not add swap; continuing without it" + fi + free -h + df -h / - uses: ./.github/actions/setup-rust-ci with: cache-key: rust-tests-${{ inputs.coverage && 'coverage' || 'plain' }} diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml index af005f31a..a22314f91 100644 --- a/.github/workflows/frontend.yaml +++ b/.github/workflows/frontend.yaml @@ -10,7 +10,7 @@ defaults: jobs: checks: name: Format, build and unit tests - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -37,7 +37,7 @@ jobs: browser: name: ${{ matrix.suite }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 20 # Keep screenshot fonts, Chromium and OS identical to the reviewed baselines. # Update together with @playwright/test in frontend/package-lock.json. diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index dbb4dfa01..123496873 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -11,21 +11,34 @@ permissions: jobs: build-image: - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 30 + runs-on: ubuntu-24.04 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 + - name: Prepare Linux build resources + if: runner.os == 'Linux' + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL + if sudo fallocate -l 16G /swap.build && sudo chmod 600 /swap.build \ + && sudo mkswap /swap.build && sudo swapon /swap.build; then + echo "Added 16G swap" + else + sudo rm -f /swap.build + echo "::warning title=Swap::Could not add swap; continuing without it" + fi + free -h + df -h / - name: Set up cached Docker builder - uses: useblacksmith/setup-docker-builder@v2 - with: - cache-key: parseable-integration + uses: docker/setup-buildx-action@v3 - name: Build integration image once - uses: useblacksmith/build-push-action@v2 + uses: docker/build-push-action@v6 with: context: . file: Dockerfile.debug platforms: linux/amd64 tags: parseable-ci:${{ github.sha }} + cache-from: type=gha,scope=parseable-integration + cache-to: type=gha,scope=parseable-integration,mode=max outputs: type=docker,dest=${{ runner.temp }}/parseable-ci.tar - uses: actions/upload-artifact@v4 with: @@ -38,7 +51,7 @@ jobs: quest: name: Quest ${{ matrix.deployment }} (${{ inputs.full && 'full' || 'smoke' }}) needs: build-image - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 40 strategy: fail-fast: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e880df45..1b244d586 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ # repository, so that job runs only in parseablehq/parseable. # It also creates a checksum file for the artifacts and publishes it to the release page. # It also builds and pushes a Docker image to Docker Hub with the same version tag. -# Linux jobs run on Blacksmith runners. The build-profile input can select the +# Linux jobs run on GitHub-hosted Ubuntu 24.04 runners. The build-profile input can select the # experimental release-thin-lto profile; such runs upload workflow artifacts # only and never publish to the release page or registry. @@ -42,7 +42,7 @@ env: jobs: build-frontend: name: Package frontend - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: write @@ -100,17 +100,32 @@ jobs: matrix: include: - target: x86_64-unknown-linux-gnu - os: blacksmith-8vcpu-ubuntu-2404 + os: ubuntu-24.04 # A hosted runner lost contact during the cross-compiled LTO build, # so aarch64 builds natively on an Arm runner. - target: aarch64-unknown-linux-gnu - os: blacksmith-8vcpu-ubuntu-2404-arm - timeout-minutes: 90 + os: ubuntu-24.04-arm + timeout-minutes: 180 steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.build-profile == 'release-thin-lto' && inputs.tag || format('refs/tags/{0}', inputs.tag) }} + - name: Prepare Linux build resources + if: runner.os == 'Linux' + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL + echo "CARGO_BUILD_JOBS=2" >> "$GITHUB_ENV" + if sudo fallocate -l 16G /swap.build && sudo chmod 600 /swap.build \ + && sudo mkswap /swap.build && sudo swapon /swap.build; then + echo "Added 16G swap" + else + sudo rm -f /swap.build + echo "::warning title=Swap::Could not add swap; continuing without it" + fi + free -h + df -h / + - name: Set up Node.js uses: actions/setup-node@v4 with: @@ -140,21 +155,6 @@ jobs: with: key: ${{ matrix.target }}-default-${{ env.RELEASE_PROFILE }} - # The aarch64 runner lost contact during the fat-LTO build twice, which - # GitHub attributes to CPU or memory starvation. Free disk, add swap and - # record the resources as annotations, which survive a lost runner. - # Swap is best effort because runner disk layouts differ. - - name: Free disk and add swap - run: | - sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL - if sudo fallocate -l 16G /swap.release && sudo chmod 600 /swap.release \ - && sudo mkswap /swap.release && sudo swapon /swap.release; then - echo "Added 16G swap" - else - echo "::warning title=Swap::Could not add swap; continuing without it" - fi - echo "::notice title=Runner resources::$(nproc) CPUs; $(free -h | awk '/Mem/{print $2" RAM"} /Swap/{print $2" swap"}' | paste -sd ' '); $(df -h --output=avail / | tail -1 | tr -d ' ') free on /" - - name: Build native run: cargo build --profile "$RELEASE_PROFILE" --target ${{ matrix.target }} @@ -332,8 +332,8 @@ jobs: build-kafka-linux: name: Build Kafka for x86_64-unknown-linux-gnu - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 90 + runs-on: ubuntu-24.04 + timeout-minutes: 180 permissions: id-token: write contents: write @@ -343,6 +343,21 @@ jobs: with: ref: ${{ inputs.build-profile == 'release-thin-lto' && inputs.tag || format('refs/tags/{0}', inputs.tag) }} + - name: Prepare Linux build resources + if: runner.os == 'Linux' + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL + echo "CARGO_BUILD_JOBS=2" >> "$GITHUB_ENV" + if sudo fallocate -l 16G /swap.build && sudo chmod 600 /swap.build \ + && sudo mkswap /swap.build && sudo swapon /swap.build; then + echo "Added 16G swap" + else + sudo rm -f /swap.build + echo "::warning title=Swap::Could not add swap; continuing without it" + fi + free -h + df -h / + - name: Set up Node.js uses: actions/setup-node@v4 with: @@ -538,7 +553,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} create-checksum: - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 needs: [build-linux, build-windows, build-mac, build-kafka-linux, build-kafka-mac] permissions: diff --git a/README.md b/README.md index 5d0dce79c..b8f015ad2 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,8 @@ For production deployments, refer to the [installation guide ↗︎](https://www ## Contributing -Pull requests use the [CI workflow](.github/workflows/ci.yaml) on Blacksmith -8-vCPU Ubuntu 24.04 runners. It selects frontend, Rust, and integration jobs from +Pull requests use the [CI workflow](.github/workflows/ci.yaml) on GitHub-hosted +Ubuntu 24.04 runners. It selects frontend, Rust, and integration jobs from the changed paths and reports one aggregate `CI passed` check. Frontend changes still exercise the embedded UI through the integration image; Rust-only changes skip the browser suite. A newer PR commit cancels its superseded CI run. From b53ac5c8477b660ab1a1295fd8ab23f58060f2ba Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:01:11 -0500 Subject: [PATCH 5/9] Add failing tests for API key privilege filter review findings Expect both SuperAdmin query parsing tests (privilege and priviledge) to fail on the PR head because the deserializer rejects superadmin. Remove the contradictory rejection case. Cover direct and group SuperAdmin roles and exact Admin matching; these helper assertions already pass. Exercise the real API key route and authentication middleware with tenant admin and reader sessions: unfiltered and filtered results, invalid query, anonymous access, and non-admin access. This regression guard passes and was confirmed to fail when the handler temporarily ignored the filter. The mutation was reverted; no production code is changed. Co-Authored-By: GPT-6.1 Sol --- src/handlers/http/apikeys.rs | 116 ++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index c74212b35..5fd6c956c 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -438,16 +438,22 @@ pub async fn get_api_key( #[cfg(test)] mod tests { + use actix_web::{App, cookie::Cookie, http::StatusCode, test as actix_test}; use parking_lot::RwLock; + use tokio::sync::Mutex; use super::*; + use crate::handlers::http::modal::server::Server; use crate::rbac::{ map::{ - self, ROLES, USER_GROUPS, USERS, UserGroups, mut_roles, mut_users, write_user_groups, + self, ROLES, SESSIONS, SessionKey, USER_GROUPS, USERS, UserGroups, mut_roles, + mut_users, write_user_groups, }, role::ParseableResourceType, }; + static SERIAL: Mutex<()> = Mutex::const_new(()); + #[test] fn expired_key_remains_visible_with_expiry_in_detail_and_list() { let mut user = User::new_api_key( @@ -522,13 +528,24 @@ mod tests { } } + #[test] + fn list_query_accepts_superadmin_privilege() { + let parsed = parse_query("privilege=superadmin").unwrap().privilege; + assert_eq!(parsed, Some(DefaultPrivilege::SuperAdmin)); + } + + #[test] + fn list_query_accepts_superadmin_priviledge_alias() { + let parsed = parse_query("priviledge=superadmin").unwrap().privilege; + assert_eq!(parsed, Some(DefaultPrivilege::SuperAdmin)); + } + #[test] fn list_query_rejects_unknown_privilege_with_bad_request() { let cases = [ ("privilege=owner", "unknown variant `owner`"), ("priviledge=owner", "unknown variant `owner`"), ("privilege=Reader", "unknown variant `Reader`"), - ("privilege=superadmin", "unknown variant `superadmin`"), ("privilege=", "unknown variant ``"), ("privilege=reader&priviledge=reader", "duplicate field"), ("privilege=reader&privilege=admin", "duplicate field"), @@ -660,6 +677,101 @@ mod tests { ); } + #[test] + fn privilege_filter_matches_superadmin_direct_and_group_roles_exactly() { + let tenant = tenant( + vec![ + ("superadmins", vec![DefaultPrivilege::SuperAdmin]), + ("admins", vec![DefaultPrivilege::Admin]), + ], + &[("superadmins-group", "superadmins")], + ); + add_key(&tenant, "superadmin-key", &["superadmins"], &[]); + add_key(&tenant, "superadmin-group-key", &[], &["superadmins-group"]); + add_key(&tenant, "admin-key", &["admins"], &[]); + assert_eq!( + names(&tenant, Some(DefaultPrivilege::SuperAdmin)), + ["superadmin-group-key", "superadmin-key"] + ); + assert_eq!(names(&tenant, Some(DefaultPrivilege::Admin)), ["admin-key"]); + } + + #[actix_web::test] + async fn list_route_filters_privilege_and_requires_admin() { + let _serial = SERIAL.lock().await; + let tenant = tenant( + vec![ + ("admins", vec![DefaultPrivilege::Admin]), + ("readers", vec![DefaultPrivilege::Reader { resource: None }]), + ], + &[], + ); + SESSIONS.get_or_init(|| RwLock::new(map::Sessions::default())); + add_key(&tenant, "admin-key", &["admins"], &[]); + add_key(&tenant, "reader-key", &["readers"], &[]); + let mut cookies = Vec::new(); + for role in ["admins", "readers"] { + let (mut user, _) = User::new_basic( + format!("apikeys-{}", Ulid::new()).to_lowercase(), + Some(tenant.clone()), + false, + ); + user.roles.insert(role.into()); + mut_users().insert(user.clone()); + let session = Ulid::new(); + Users.new_session( + &user, + SessionKey::SessionId(session), + chrono::TimeDelta::hours(1), + ); + cookies.push(Cookie::new("session", session.to_string())); + } + let app = actix_test::init_service( + App::new().service(web::scope("/api/prism/v1").service(Server::get_apikeys_webscope())), + ) + .await; + for (query, expected) in [ + ("", vec!["admin-key", "reader-key"]), + ("?privilege=admin", vec!["admin-key"]), + ] { + let req = actix_test::TestRequest::get() + .uri(&format!("/api/prism/v1/apikeys{query}")) + .cookie(cookies[0].clone()) + .to_request(); + let response = actix_test::call_service(&app, req).await; + assert_eq!(response.status(), StatusCode::OK, "{query}"); + let body: serde_json::Value = actix_test::read_body_json(response).await; + let mut names: Vec<_> = body + .as_array() + .unwrap() + .iter() + .map(|entry| entry["keyName"].as_str().unwrap()) + .collect(); + names.sort(); + assert_eq!(names, expected, "{query}"); + } + for (query, cookie, expected) in [ + ( + "?privilege=bogus", + Some(&cookies[0]), + StatusCode::BAD_REQUEST, + ), + ("", None, StatusCode::UNAUTHORIZED), + ("", Some(&cookies[1]), StatusCode::FORBIDDEN), + ] { + let mut req = + actix_test::TestRequest::get().uri(&format!("/api/prism/v1/apikeys{query}")); + if let Some(cookie) = cookie { + req = req.cookie(cookie.clone()); + } + let status = match actix_test::try_call_service(&app, req.to_request()).await { + Ok(response) => response.status(), + Err(error) => error.as_response_error().status_code(), + }; + assert_eq!(status, expected, "{query}"); + } + } + #[test] fn privilege_filter_includes_group_inherited_roles() { let tenant = tenant( From ebd451f18aae69d9ee47bf08c43b3dcc29d1a6c7 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:09:06 -0500 Subject: [PATCH 6/9] Accept `superadmin` in API key privilege filter Privileges still match by kind, so `admin` does not match super-admin keys. Co-Authored-By: Claude Opus 5.5 --- frontend/docs/api-contracts.md | 2 +- src/handlers/http/apikeys.rs | 26 +++++++++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md index d4a86883f..e7f3438c6 100644 --- a/frontend/docs/api-contracts.md +++ b/frontend/docs/api-contracts.md @@ -49,7 +49,7 @@ Team requests share the cookie session and `credentials: 'include'`. The adminis | `getApiKey(id, signal?)` | `GET /api/prism/v1/apikeys/{encoded id}` | Full key, retrieved only on an explicit copy action | | `deleteApiKey(id)` | `DELETE /api/prism/v1/apikeys/{encoded id}` | Checked success; backend returns `{keyId, keyName, message}` | -API key listing accepts `?privilege=reader|ingestor|admin|writer|editor` (alias `priviledge`), matching direct or group-inherited role privileges by kind (an admin key does not match `reader`). Each entry's `roles` lists direct roles only. Unknown or differently cased values, or a repeated parameter, return 400; unknown parameter names are ignored and leave the list unfiltered. +API key listing accepts `?privilege=reader|ingestor|admin|writer|editor|superadmin` (alias `priviledge`), matching direct or group-inherited role privileges by kind (an admin key does not match `reader`, and a super-admin key does not match `admin`). Each entry's `roles` lists direct roles only. Unknown or differently cased values, or a repeated parameter, return 400; unknown parameter names are ignored and leave the list unfiltered. API key expiry is optional: `ttlSeconds` must be an integer from 1800 (30 minutes) through 7776000 (90 days), inclusive; invalid values return 400. Omitted or null TTL means no expiration. Responses include `expiresAt`, a UTC timestamp or null; the frontend also accepts an omitted field from older servers for legacy records. Finite lifetimes are elapsed durations from creation, independent of time zones or daylight saving changes. The creation form defaults to 90 days and offers presets, custom minutes/hours/days, and No expiration. TTL cannot be edited after creation. Expired keys remain listed and deletable; their names remain reserved until deletion. diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index 5fd6c956c..1c1457fc6 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -101,10 +101,12 @@ pub struct ValidateApiKeyRequest { api_key: String, } -/// Query params for `GET /apikeys`. `privilege` is the canonical name; -/// `priviledge` is accepted as an alias for compatibility with upstream -/// Parseable clients, which use that spelling. Unknown parameter names, such as -/// `Privilege`, are ignored and leave the list unfiltered. +/// Query params for `GET /apikeys`. `privilege` takes one of `reader`, +/// `ingestor`, `admin`, `writer`, `editor` or `superadmin`, matched by kind. +/// `privilege` is the canonical name; `priviledge` is accepted as an alias for +/// compatibility with upstream Parseable clients, which use that spelling. +/// Unknown parameter names, such as `Privilege`, are ignored and leave the list +/// unfiltered. #[derive(Debug, Deserialize)] pub struct ListApiKeysQuery { #[serde( @@ -128,9 +130,17 @@ where "admin" => Ok(DefaultPrivilege::Admin), "writer" => Ok(DefaultPrivilege::Writer { resource: None }), "editor" => Ok(DefaultPrivilege::Editor), + "superadmin" => Ok(DefaultPrivilege::SuperAdmin), _ => Err(de::Error::unknown_variant( &privilege, - &["reader", "ingestor", "admin", "writer", "editor"], + &[ + "reader", + "ingestor", + "admin", + "writer", + "editor", + "superadmin", + ], )), }) .transpose() @@ -385,8 +395,10 @@ pub async fn delete_api_key( /// GET /api/prism/v1/apikeys /// /// List all API keys (masked). Only admins can list keys. Optionally filter by -/// role privilege with `?privilege=reader|ingestor|admin|writer|editor` -/// (`?priviledge=` is accepted too). An unknown value is rejected with 400. +/// role privilege with +/// `?privilege=reader|ingestor|admin|writer|editor|superadmin` (`?priviledge=` +/// is accepted too). Privileges match by kind, so `admin` does not match +/// super-admin keys. An unknown value is rejected with 400. /// Unknown parameter names are ignored and return the unfiltered list. pub async fn list_api_keys( req: HttpRequest, From 05d756e440eb0509b3da95eef80d9d017c8e6e4c Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:18:02 -0500 Subject: [PATCH 7/9] Release RBAC guards before scanning keys in API key privilege filter The filtered list held the user groups and roles read guards while checking every key against every role and privilege, so a queued writer on either map blocked `check_auth` for all tenants for the whole scan. Compute only the tenant's granting role names and the groups holding one of them under the guards (groups then roles, per the `map` lock order), then filter keys against those owned sets. Semantics are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/handlers/http/apikeys.rs | 60 +++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index 1c1457fc6..a3dcfb28b 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use actix_web::{HttpRequest, HttpResponse, Responder, web}; use chrono::{DateTime, Utc}; @@ -16,7 +16,7 @@ use crate::{ Users, map::{read_user_groups, roles, users}, role::model::{DefaultPrivilege, Role}, - user::{User, UserGroup, UserType}, + user::{User, UserType}, }, utils::{get_user_and_tenant_from_request, is_admin}, }; @@ -152,28 +152,9 @@ fn role_has_privilege(role: &Role, privilege: &DefaultPrivilege) -> bool { .any(|candidate| std::mem::discriminant(candidate) == std::mem::discriminant(privilege)) } -/// Whether any direct or group-inherited role grants `privilege`. -/// Groups and roles missing from the tenant maps never match. -fn user_has_privilege( - user: &User, - tenant_roles: Option<&HashMap>, - tenant_groups: Option<&HashMap>, - privilege: &DefaultPrivilege, -) -> bool { - let group_roles = user - .user_groups - .iter() - .filter_map(|group_name| tenant_groups.and_then(|groups| groups.get(group_name))) - .flat_map(|group| &group.roles); - user.roles.iter().chain(group_roles).any(|role_name| { - tenant_roles - .and_then(|roles| roles.get(role_name)) - .is_some_and(|role| role_has_privilege(role, privilege)) - }) -} - /// Masked list entries for the tenant's API keys whose direct or group-inherited /// roles grant `privilege`, or all of them when `privilege` is `None`. +/// Groups and roles missing from the tenant maps never match. fn tenant_entries( tenant_id: &Option, privilege: Option<&DefaultPrivilege>, @@ -182,14 +163,34 @@ fn tenant_entries( let matching: Vec<&User> = match privilege { None => keys.iter().collect(), Some(privilege) => { - // Lock order: user groups before roles. Both guards drop at the end - // of this arm, before entries are built. - let all_groups = read_user_groups(); - let all_roles = roles(); let tenant = tenant_or_default(tenant_id); - let (tenant_roles, tenant_groups) = (all_roles.get(tenant), all_groups.get(tenant)); + // Lock order: user groups before roles. Only the granting role and + // group names are computed under the guards; both drop before the + // per-key scan so it never blocks writers or the readers behind them. + let (granting_roles, granting_groups) = { + let all_groups = read_user_groups(); + let all_roles = roles(); + let granting_roles: HashSet = all_roles + .get(tenant) + .into_iter() + .flatten() + .filter(|(_, role)| role_has_privilege(role, privilege)) + .map(|(name, _)| name.clone()) + .collect(); + let granting_groups: HashSet = all_groups + .get(tenant) + .into_iter() + .flatten() + .filter(|(_, group)| !group.roles.is_disjoint(&granting_roles)) + .map(|(name, _)| name.clone()) + .collect(); + (granting_roles, granting_groups) + }; keys.iter() - .filter(|user| user_has_privilege(user, tenant_roles, tenant_groups, privilege)) + .filter(|user| { + !user.roles.is_disjoint(&granting_roles) + || !user.user_groups.is_disjoint(&granting_groups) + }) .collect() } }; @@ -450,6 +451,8 @@ pub async fn get_api_key( #[cfg(test)] mod tests { + use std::collections::HashMap; + use actix_web::{App, cookie::Cookie, http::StatusCode, test as actix_test}; use parking_lot::RwLock; use tokio::sync::Mutex; @@ -462,6 +465,7 @@ mod tests { mut_users, write_user_groups, }, role::ParseableResourceType, + user::UserGroup, }; static SERIAL: Mutex<()> = Mutex::const_new(()); From 74c375aab7fe75cbd1f7b033a88331fb23aa5f31 Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:21:27 -0500 Subject: [PATCH 8/9] Serialize every test that writes the shared RBAC maps `rbac::lock_order_tests` detect progress by observing a queued writer on USERS, but the API key tests wrote USERS, ROLES and USER_GROUPS without their serialization, so an outside writer could fail them spuriously or let them pass vacuously. Replace the per-module locks in lock_order_tests, api_key_auth_tests and the API key tests with one `map::TEST_SERIAL`. It is a tokio mutex so sync tests can `blocking_lock` it and async tests can await it, and it does not poison when a test panics. Co-Authored-By: Claude Opus 5.5 --- src/handlers/http/apikeys.rs | 40 ++++++++++++++++++++++++--------- src/handlers/http/middleware.rs | 12 +++++----- src/rbac/map.rs | 7 ++++++ src/rbac/mod.rs | 15 ++++++------- 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index a3dcfb28b..ee473c3d4 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -455,21 +455,19 @@ mod tests { use actix_web::{App, cookie::Cookie, http::StatusCode, test as actix_test}; use parking_lot::RwLock; - use tokio::sync::Mutex; + use tokio::sync::MutexGuard; use super::*; use crate::handlers::http::modal::server::Server; use crate::rbac::{ map::{ - self, ROLES, SESSIONS, SessionKey, USER_GROUPS, USERS, UserGroups, mut_roles, - mut_users, write_user_groups, + self, ROLES, SESSIONS, SessionKey, TEST_SERIAL, USER_GROUPS, USERS, UserGroups, + mut_roles, mut_users, write_user_groups, }, role::ParseableResourceType, user::UserGroup, }; - static SERIAL: Mutex<()> = Mutex::const_new(()); - #[test] fn expired_key_remains_visible_with_expiry_in_detail_and_list() { let mut user = User::new_api_key( @@ -577,12 +575,30 @@ mod tests { } } - // `map::init` needs `PARSEABLE`, so tests create the maps directly. Each - // test uses its own tenant, so they don't see each other's keys or roles. - fn tenant(roles: Vec<(&str, Vec)>, groups: &[(&str, &str)]) -> String { + // `map::init` needs `PARSEABLE`, so tests create the maps directly. Tests + // that write them hold `map::TEST_SERIAL`, as other modules' tests watch the + // map locks. Each test uses its own tenant, so they don't see each other's + // keys or roles. + async fn rbac_state() -> MutexGuard<'static, ()> { + let serial = TEST_SERIAL.lock().await; + init_rbac_maps(); + serial + } + + fn blocking_rbac_state() -> MutexGuard<'static, ()> { + let serial = TEST_SERIAL.blocking_lock(); + init_rbac_maps(); + serial + } + + fn init_rbac_maps() { USERS.get_or_init(|| RwLock::new(map::Users::default())); + SESSIONS.get_or_init(|| RwLock::new(map::Sessions::default())); USER_GROUPS.get_or_init(|| RwLock::new(UserGroups::default())); ROLES.get_or_init(|| RwLock::new(HashMap::new())); + } + + fn tenant(roles: Vec<(&str, Vec)>, groups: &[(&str, &str)]) -> String { let tenant = format!("apikeys-{}", Ulid::new()); mut_roles().insert( tenant.clone(), @@ -626,6 +642,7 @@ mod tests { #[test] fn list_without_privilege_returns_every_key_masked() { + let _serial = blocking_rbac_state(); let tenant = tenant( vec![("readers", vec![DefaultPrivilege::Reader { resource: None }])], &[], @@ -643,6 +660,7 @@ mod tests { #[test] fn privilege_filter_matches_keys_by_role_privileges() { + let _serial = blocking_rbac_state(); let tenant = tenant( vec![ ( @@ -695,6 +713,7 @@ mod tests { #[test] fn privilege_filter_matches_superadmin_direct_and_group_roles_exactly() { + let _serial = blocking_rbac_state(); let tenant = tenant( vec![ ("superadmins", vec![DefaultPrivilege::SuperAdmin]), @@ -714,7 +733,7 @@ mod tests { #[actix_web::test] async fn list_route_filters_privilege_and_requires_admin() { - let _serial = SERIAL.lock().await; + let _serial = rbac_state().await; let tenant = tenant( vec![ ("admins", vec![DefaultPrivilege::Admin]), @@ -722,7 +741,6 @@ mod tests { ], &[], ); - SESSIONS.get_or_init(|| RwLock::new(map::Sessions::default())); add_key(&tenant, "admin-key", &["admins"], &[]); add_key(&tenant, "reader-key", &["readers"], &[]); let mut cookies = Vec::new(); @@ -790,6 +808,7 @@ mod tests { #[test] fn privilege_filter_includes_group_inherited_roles() { + let _serial = blocking_rbac_state(); let tenant = tenant( vec![("readers", vec![DefaultPrivilege::Reader { resource: None }])], &[ @@ -809,6 +828,7 @@ mod tests { #[test] fn privilege_filter_uses_the_callers_tenant_roles() { + let _serial = blocking_rbac_state(); // Same role and group names, different privileges per tenant. let readers = tenant( vec![("team", vec![DefaultPrivilege::Reader { resource: None }])], diff --git a/src/handlers/http/middleware.rs b/src/handlers/http/middleware.rs index 42057756b..cc6d2bc02 100644 --- a/src/handlers/http/middleware.rs +++ b/src/handlers/http/middleware.rs @@ -1118,13 +1118,13 @@ mod api_key_auth_tests { use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::DateTime; use parking_lot::RwLock; - use tokio::sync::{Mutex, MutexGuard}; + use tokio::sync::MutexGuard; use super::*; use crate::rbac::{ map::{ - ROLES, SESSIONS, Sessions, USER_GROUPS, USERS, UserGroups, Users as UserMap, mut_roles, - mut_users, + ROLES, SESSIONS, Sessions, TEST_SERIAL, USER_GROUPS, USERS, UserGroups, + Users as UserMap, mut_roles, mut_users, }, role::model::{DefaultPrivilege, Role}, }; @@ -1134,19 +1134,17 @@ mod api_key_auth_tests { type AuthMethod = fn(&mut ServiceRequest, Action) -> Result; - static SERIAL: Mutex<()> = Mutex::const_new(()); - // `rbac::map::init` needs `PARSEABLE`, so tests create the maps directly. // Tests use their own users but run one at a time: concurrent first Basic // logins and session checks take the user and session locks in opposite order. async fn auth_state() -> MutexGuard<'static, ()> { - let serial = SERIAL.lock().await; + let serial = TEST_SERIAL.lock().await; init_auth_maps(); serial } fn blocking_auth_state() -> MutexGuard<'static, ()> { - let serial = SERIAL.blocking_lock(); + let serial = TEST_SERIAL.blocking_lock(); init_auth_maps(); serial } diff --git a/src/rbac/map.rs b/src/rbac/map.rs index bbb2a3e20..1f90b6f8a 100644 --- a/src/rbac/map.rs +++ b/src/rbac/map.rs @@ -53,6 +53,13 @@ pub static DEFAULT_ROLE: Lazy>>> = pub static SESSIONS: OnceCell> = OnceCell::new(); pub static USER_GROUPS: OnceCell> = OnceCell::new(); +/// Held by every test that writes the maps above. Some tests detect progress +/// by observing a queued writer, so an unrelated writer can fail them or let +/// them pass vacuously. Sync tests take it with `blocking_lock`, async tests +/// with `lock().await`; unlike a std mutex, a panicking test doesn't poison it. +#[cfg(test)] +pub(crate) static TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + pub fn read_user_groups() -> RwLockReadGuard<'static, UserGroups> { USER_GROUPS .get() diff --git a/src/rbac/mod.rs b/src/rbac/mod.rs index 222b16003..30c044ff0 100644 --- a/src/rbac/mod.rs +++ b/src/rbac/mod.rs @@ -490,7 +490,7 @@ mod lock_order_tests { use std::io::Write; use std::sync::{ - Arc, Mutex, MutexGuard, + Arc, atomic::{AtomicBool, Ordering}, mpsc, }; @@ -500,13 +500,15 @@ mod lock_order_tests { use actix_web::cookie::Cookie; use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version, password_hash::SaltString}; use parking_lot::RwLock; + use tokio::sync::MutexGuard; use ulid::Ulid; use super::*; use crate::handlers::http::modal::query::querier_rbac::remove_user_from_groups; use crate::handlers::http::oidc::exchange_basic_for_cookie; use crate::rbac::map::{ - ROLES, SESSIONS, Sessions, USER_GROUPS, USERS, UserGroups, mut_roles, write_user_groups, + ROLES, SESSIONS, Sessions, TEST_SERIAL, USER_GROUPS, USERS, UserGroups, mut_roles, + write_user_groups, }; use crate::rbac::role::model::{DefaultPrivilege, Role}; use crate::rbac::user::{Basic, GroupUser, UserGroup}; @@ -515,14 +517,11 @@ mod lock_order_tests { // Generous for debug builds; a deadlocked thread never finishes. const DEADLINE: Duration = Duration::from_secs(20); - static SERIAL: Mutex<()> = Mutex::new(()); - // `map::init` needs `PARSEABLE`, so tests create the maps directly. They hold - // the shared locks on purpose, so they run one at a time. + // the shared locks on purpose, so they run one at a time and apart from every + // other test that writes the maps (`map::TEST_SERIAL`). fn auth_state() -> MutexGuard<'static, ()> { - let serial = SERIAL - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let serial = TEST_SERIAL.blocking_lock(); USERS.get_or_init(|| RwLock::new(map::Users::default())); SESSIONS.get_or_init(|| RwLock::new(Sessions::default())); USER_GROUPS.get_or_init(|| RwLock::new(UserGroups::default())); From 33457a5ed731f4e6181616867c0dbb2d41ba57eb Mon Sep 17 00:00:00 2001 From: VibeSWE <338779468+VibeSWE@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:21:52 -0500 Subject: [PATCH 9/9] Rename tenant-scoped privilege filter test The test calls `tenant_entries` with an explicit tenant, not a caller. Co-Authored-By: Claude Opus 5.5 --- src/handlers/http/apikeys.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index ee473c3d4..fa2c79999 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -827,7 +827,7 @@ mod tests { } #[test] - fn privilege_filter_uses_the_callers_tenant_roles() { + fn privilege_filter_resolves_roles_in_the_given_tenant() { let _serial = blocking_rbac_state(); // Same role and group names, different privileges per tenant. let readers = tenant(