From 8f64c36937b16b8291d10d8d870c9a787d090a9b Mon Sep 17 00:00:00 2001 From: Pratik Jadhav Date: Wed, 7 Oct 2026 15:45:49 +0530 Subject: [PATCH 1/7] feat: accept caller-provided API keys --- src/apikeys.rs | 26 +++++++++++++++++++++++++- src/handlers/http/apikeys.rs | 16 ++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/apikeys.rs b/src/apikeys.rs index d139b351a..b1db6879f 100644 --- a/src/apikeys.rs +++ b/src/apikeys.rs @@ -30,12 +30,27 @@ use serde::Deserialize; /// Request body for creating a new API key. `roles` is a set of role names /// that must already exist in the tenant; permissions for the backing user /// are derived from these roles (same flow as native/OAuth users). +/// `providedApiKey` lets a caller register their own key; omitting it preserves +/// the existing server-generated key behavior. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateApiKeyRequest { pub key_name: String, #[serde(default)] pub roles: HashSet, + pub provided_api_key: Option, +} + +/// Reject short or malformed caller-supplied credentials before persisting them. +pub fn validate_provided_api_key(value: &str) -> Result<(), ApiKeyError> { + if !(32..=256).contains(&value.len()) + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err(ApiKeyError::InvalidProvidedApiKey); + } + Ok(()) } #[derive(Debug, thiserror::Error)] @@ -46,6 +61,12 @@ pub enum ApiKeyError { #[error("Duplicate key name: {0}")] DuplicateKeyName(String), + #[error("API key value is already registered")] + DuplicateApiKey, + + #[error("providedApiKey must be 32-256 ASCII letters, digits, hyphens, or underscores")] + InvalidProvidedApiKey, + #[error("Unauthorized: {0}")] Unauthorized(String), @@ -66,7 +87,10 @@ impl actix_web::ResponseError for ApiKeyError { fn status_code(&self) -> actix_web::http::StatusCode { match self { ApiKeyError::KeyNotFound(_) => actix_web::http::StatusCode::NOT_FOUND, - ApiKeyError::DuplicateKeyName(_) => actix_web::http::StatusCode::CONFLICT, + ApiKeyError::DuplicateKeyName(_) | ApiKeyError::DuplicateApiKey => { + actix_web::http::StatusCode::CONFLICT + } + ApiKeyError::InvalidProvidedApiKey => actix_web::http::StatusCode::BAD_REQUEST, ApiKeyError::Unauthorized(_) => actix_web::http::StatusCode::FORBIDDEN, ApiKeyError::RevocationSyncFailed(_) => { actix_web::http::StatusCode::SERVICE_UNAVAILABLE diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index 333831ee6..ecf21f32e 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Deserializer, Serialize, de}; use ulid::Ulid; use crate::{ - apikeys::{ApiKeyError, CreateApiKeyRequest}, + apikeys::{ApiKeyError, CreateApiKeyRequest, validate_provided_api_key}, handlers::http::{ modal::utils::rbac_utils::{get_metadata, put_metadata}, rbac::{RBACError, UPDATE_LOCK}, @@ -221,7 +221,19 @@ pub async fn create_api_key( } let key_id = Ulid::new(); - let api_key_value = generate_api_key_value(); + let api_key_value = if let Some(provided_api_key) = body.provided_api_key { + validate_provided_api_key(&provided_api_key)?; + provided_api_key + } else { + generate_api_key_value() + }; + if existing + .iter() + .filter_map(|user: &User| user.as_api_key()) + .any(|key| key.api_key == api_key_value) + { + return Err(ApiKeyError::DuplicateApiKey); + } let user = User::new_api_key( key_id, api_key_value, From d4d06e0a45075feffe03ab8152a03cc25099c07c Mon Sep 17 00:00:00 2001 From: Pratik Jadhav Date: Wed, 21 Oct 2026 15:15:20 +0530 Subject: [PATCH 2/7] feat: add ingest-only mode for caller-provided API keys --- src/apikeys.rs | 33 +++++++++++++++++++-------------- src/handlers/http/apikeys.rs | 27 ++++++++++++++++++--------- src/rbac/mod.rs | 21 +++++++++++++++++++++ 3 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/apikeys.rs b/src/apikeys.rs index b1db6879f..36be6c9e3 100644 --- a/src/apikeys.rs +++ b/src/apikeys.rs @@ -30,25 +30,25 @@ use serde::Deserialize; /// Request body for creating a new API key. `roles` is a set of role names /// that must already exist in the tenant; permissions for the backing user /// are derived from these roles (same flow as native/OAuth users). -/// `providedApiKey` lets a caller register their own key; omitting it preserves +/// `apiKey` lets a caller register their own key; omitting it preserves /// the existing server-generated key behavior. #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CreateApiKeyRequest { pub key_name: String, #[serde(default)] pub roles: HashSet, - pub provided_api_key: Option, + pub api_key: Option, + /// Helm uses this mode to request the server-managed global ingest role. + #[serde(default)] + pub ingestor_only: bool, } -/// Reject short or malformed caller-supplied credentials before persisting them. -pub fn validate_provided_api_key(value: &str) -> Result<(), ApiKeyError> { - if !(32..=256).contains(&value.len()) - || !value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') - { - return Err(ApiKeyError::InvalidProvidedApiKey); +/// Require a hyphenated UUID v4, allowing either letter case. +pub fn validate_api_key(value: &str) -> Result<(), ApiKeyError> { + let uuid = uuid::Uuid::parse_str(value).map_err(|_| ApiKeyError::InvalidApiKey)?; + if value.len() != 36 || uuid.get_version_num() != 4 { + return Err(ApiKeyError::InvalidApiKey); } Ok(()) } @@ -64,8 +64,11 @@ pub enum ApiKeyError { #[error("API key value is already registered")] DuplicateApiKey, - #[error("providedApiKey must be 32-256 ASCII letters, digits, hyphens, or underscores")] - InvalidProvidedApiKey, + #[error("apiKey must be a UUID v4 in 8-4-4-4-12 format")] + InvalidApiKey, + + #[error("ingestorOnly cannot be combined with roles")] + IngestorOnlyWithRoles, #[error("Unauthorized: {0}")] Unauthorized(String), @@ -90,7 +93,9 @@ impl actix_web::ResponseError for ApiKeyError { ApiKeyError::DuplicateKeyName(_) | ApiKeyError::DuplicateApiKey => { actix_web::http::StatusCode::CONFLICT } - ApiKeyError::InvalidProvidedApiKey => actix_web::http::StatusCode::BAD_REQUEST, + ApiKeyError::InvalidApiKey | ApiKeyError::IngestorOnlyWithRoles => { + actix_web::http::StatusCode::BAD_REQUEST + } ApiKeyError::Unauthorized(_) => actix_web::http::StatusCode::FORBIDDEN, ApiKeyError::RevocationSyncFailed(_) => { actix_web::http::StatusCode::SERVICE_UNAVAILABLE diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index ecf21f32e..6423decec 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -6,14 +6,14 @@ use serde::{Deserialize, Deserializer, Serialize, de}; use ulid::Ulid; use crate::{ - apikeys::{ApiKeyError, CreateApiKeyRequest, validate_provided_api_key}, + apikeys::{ApiKeyError, CreateApiKeyRequest, validate_api_key as validate_api_key_value}, handlers::http::{ modal::utils::rbac_utils::{get_metadata, put_metadata}, rbac::{RBACError, UPDATE_LOCK}, }, parseable::DEFAULT_TENANT, rbac::{ - Users, + GLOBAL_INGESTOR_ROLE, Users, map::{roles, users}, role::model::{DefaultPrivilege, Role}, user::{User, UserType}, @@ -206,9 +206,18 @@ pub async fn create_api_key( let guard = UPDATE_LOCK.lock().await; - // Reject any role names that don't exist in this tenant while holding the - // update lock so a concurrent role update cannot invalidate the check. - validate_roles(&body.roles, &tenant_id)?; + // Helm's ingest-only mode uses an internal role. Explicit roles retain + // the existing UI/API behavior and cannot be mixed with this mode. + let assigned_roles = if body.ingestor_only { + if !body.roles.is_empty() { + return Err(ApiKeyError::IngestorOnlyWithRoles); + } + HashSet::from([GLOBAL_INGESTOR_ROLE.to_string()]) + } else { + // Hold the update lock while checking user-defined roles. + validate_roles(&body.roles, &tenant_id)?; + body.roles + }; // Duplicate key-name detection inside the same tenant. let existing = collect_tenant_api_keys(&tenant_id); @@ -221,9 +230,9 @@ pub async fn create_api_key( } let key_id = Ulid::new(); - let api_key_value = if let Some(provided_api_key) = body.provided_api_key { - validate_provided_api_key(&provided_api_key)?; - provided_api_key + let api_key_value = if let Some(api_key) = body.api_key { + validate_api_key_value(&api_key)?; + api_key } else { generate_api_key_value() }; @@ -238,7 +247,7 @@ pub async fn create_api_key( key_id, api_key_value, body.key_name, - body.roles, + assigned_roles, created_by, tenant_id.clone(), ); diff --git a/src/rbac/mod.rs b/src/rbac/mod.rs index 0e7dda57d..6898a86fc 100644 --- a/src/rbac/mod.rs +++ b/src/rbac/mod.rs @@ -45,6 +45,9 @@ use self::role::{Permission, RoleBuilder}; use self::user::UserType; pub const EXPIRY_DURATION: Duration = Duration::hours(1); +/// Reserved, server-managed role for Helm's ingest-only API keys. +/// This name is outside the user-defined role-name grammar. +pub const GLOBAL_INGESTOR_ROLE: &str = "__global_ingestor__"; #[derive(PartialEq, Debug)] pub enum Response { @@ -397,6 +400,10 @@ pub struct UsersPrism { pub fn roles_to_permission(roles: Vec, tenant_id: &str) -> Vec { let mut perms = HashSet::new(); for role in &roles { + if role == GLOBAL_INGESTOR_ROLE { + perms.insert(Permission::Resource(Action::Ingest, None)); + continue; + } let role_map = &map::roles(); if let Some(roles) = role_map.get(tenant_id) && let Some(privilege_list) = roles.get(role) @@ -410,3 +417,17 @@ pub fn roles_to_permission(roles: Vec, tenant_id: &str) -> Vec Date: Wed, 21 Oct 2026 21:31:32 +0530 Subject: [PATCH 3/7] feat: support typed API keys with global reader access --- src/apikeys.rs | 46 +++++++++++++++++++++++++++++++----- src/handlers/http/apikeys.rs | 33 ++++++++++++++++---------- src/rbac/mod.rs | 28 ++++++++++++++++++++-- 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/src/apikeys.rs b/src/apikeys.rs index 36be6c9e3..cc0030b74 100644 --- a/src/apikeys.rs +++ b/src/apikeys.rs @@ -32,6 +32,13 @@ use serde::Deserialize; /// are derived from these roles (same flow as native/OAuth users). /// `apiKey` lets a caller register their own key; omitting it preserves /// the existing server-generated key behavior. +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ApiKeyType { + Ingestor, + Query, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CreateApiKeyRequest { @@ -39,9 +46,8 @@ pub struct CreateApiKeyRequest { #[serde(default)] pub roles: HashSet, pub api_key: Option, - /// Helm uses this mode to request the server-managed global ingest role. - #[serde(default)] - pub ingestor_only: bool, + /// Omit for the existing UI/API flow that assigns explicit roles. + pub api_key_type: Option, } /// Require a hyphenated UUID v4, allowing either letter case. @@ -67,8 +73,8 @@ pub enum ApiKeyError { #[error("apiKey must be a UUID v4 in 8-4-4-4-12 format")] InvalidApiKey, - #[error("ingestorOnly cannot be combined with roles")] - IngestorOnlyWithRoles, + #[error("apiKeyType cannot be combined with roles")] + ApiKeyTypeWithRoles, #[error("Unauthorized: {0}")] Unauthorized(String), @@ -93,7 +99,7 @@ impl actix_web::ResponseError for ApiKeyError { ApiKeyError::DuplicateKeyName(_) | ApiKeyError::DuplicateApiKey => { actix_web::http::StatusCode::CONFLICT } - ApiKeyError::InvalidApiKey | ApiKeyError::IngestorOnlyWithRoles => { + ApiKeyError::InvalidApiKey | ApiKeyError::ApiKeyTypeWithRoles => { actix_web::http::StatusCode::BAD_REQUEST } ApiKeyError::Unauthorized(_) => actix_web::http::StatusCode::FORBIDDEN, @@ -107,3 +113,31 @@ impl actix_web::ResponseError for ApiKeyError { } } } + +#[cfg(test)] +mod request_tests { + use super::{ApiKeyType, CreateApiKeyRequest}; + + #[test] + fn typed_keys_and_existing_role_requests_deserialize() { + let ingestor: CreateApiKeyRequest = + serde_json::from_str(r#"{"keyName":"ingest-key","apiKeyType":"ingestor"}"#).unwrap(); + assert_eq!(ingestor.api_key_type, Some(ApiKeyType::Ingestor)); + + let query: CreateApiKeyRequest = + serde_json::from_str(r#"{"keyName":"query-key","apiKeyType":"query"}"#).unwrap(); + assert_eq!(query.api_key_type, Some(ApiKeyType::Query)); + + let ui: CreateApiKeyRequest = + serde_json::from_str(r#"{"keyName":"ui-key","roles":["reader-role"]}"#).unwrap(); + assert_eq!(ui.api_key_type, None); + assert!(ui.roles.contains("reader-role")); + + assert!( + serde_json::from_str::( + r#"{"keyName":"old-key","ingestorOnly":true}"# + ) + .is_err() + ); + } +} diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index 6423decec..6507f2cca 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -6,14 +6,16 @@ use serde::{Deserialize, Deserializer, Serialize, de}; use ulid::Ulid; use crate::{ - apikeys::{ApiKeyError, CreateApiKeyRequest, validate_api_key as validate_api_key_value}, + apikeys::{ + ApiKeyError, ApiKeyType, CreateApiKeyRequest, validate_api_key as validate_api_key_value, + }, handlers::http::{ modal::utils::rbac_utils::{get_metadata, put_metadata}, rbac::{RBACError, UPDATE_LOCK}, }, parseable::DEFAULT_TENANT, rbac::{ - GLOBAL_INGESTOR_ROLE, Users, + GLOBAL_INGESTOR_ROLE, GLOBAL_QUERIER_ROLE, Users, map::{roles, users}, role::model::{DefaultPrivilege, Role}, user::{User, UserType}, @@ -206,17 +208,24 @@ pub async fn create_api_key( let guard = UPDATE_LOCK.lock().await; - // Helm's ingest-only mode uses an internal role. Explicit roles retain - // the existing UI/API behavior and cannot be mixed with this mode. - let assigned_roles = if body.ingestor_only { - if !body.roles.is_empty() { - return Err(ApiKeyError::IngestorOnlyWithRoles); + // Typed keys get server-managed roles; the existing UI/API flow keeps + // assigning explicit roles when apiKeyType is omitted. + let assigned_roles = match body.api_key_type { + Some(api_key_type) => { + if !body.roles.is_empty() { + return Err(ApiKeyError::ApiKeyTypeWithRoles); + } + let role = match api_key_type { + ApiKeyType::Ingestor => GLOBAL_INGESTOR_ROLE, + ApiKeyType::Query => GLOBAL_QUERIER_ROLE, + }; + HashSet::from([role.to_string()]) + } + None => { + // Hold the update lock while checking user-defined roles. + validate_roles(&body.roles, &tenant_id)?; + body.roles } - HashSet::from([GLOBAL_INGESTOR_ROLE.to_string()]) - } else { - // Hold the update lock while checking user-defined roles. - validate_roles(&body.roles, &tenant_id)?; - body.roles }; // Duplicate key-name detection inside the same tenant. diff --git a/src/rbac/mod.rs b/src/rbac/mod.rs index 6898a86fc..275360a03 100644 --- a/src/rbac/mod.rs +++ b/src/rbac/mod.rs @@ -41,13 +41,15 @@ use crate::rbac::user::User; use crate::utils::get_tenant_id_from_key; use self::map::SessionKey; -use self::role::{Permission, RoleBuilder}; +use self::role::{ParseableResourceType, Permission, RoleBuilder, model::DefaultPrivilege}; use self::user::UserType; pub const EXPIRY_DURATION: Duration = Duration::hours(1); /// Reserved, server-managed role for Helm's ingest-only API keys. /// This name is outside the user-defined role-name grammar. pub const GLOBAL_INGESTOR_ROLE: &str = "__global_ingestor__"; +/// Reserved, server-managed role with the existing reader privilege on all datasets. +pub const GLOBAL_QUERIER_ROLE: &str = "__global_querier__"; #[derive(PartialEq, Debug)] pub enum Response { @@ -404,6 +406,15 @@ pub fn roles_to_permission(roles: Vec, tenant_id: &str) -> Vec, tenant_id: &str) -> Vec Date: Wed, 21 Oct 2026 22:16:29 +0530 Subject: [PATCH 4/7] chore: remove API key tests --- src/apikeys.rs | 28 ---------------------------- src/rbac/mod.rs | 27 --------------------------- 2 files changed, 55 deletions(-) diff --git a/src/apikeys.rs b/src/apikeys.rs index cc0030b74..5a0682135 100644 --- a/src/apikeys.rs +++ b/src/apikeys.rs @@ -113,31 +113,3 @@ impl actix_web::ResponseError for ApiKeyError { } } } - -#[cfg(test)] -mod request_tests { - use super::{ApiKeyType, CreateApiKeyRequest}; - - #[test] - fn typed_keys_and_existing_role_requests_deserialize() { - let ingestor: CreateApiKeyRequest = - serde_json::from_str(r#"{"keyName":"ingest-key","apiKeyType":"ingestor"}"#).unwrap(); - assert_eq!(ingestor.api_key_type, Some(ApiKeyType::Ingestor)); - - let query: CreateApiKeyRequest = - serde_json::from_str(r#"{"keyName":"query-key","apiKeyType":"query"}"#).unwrap(); - assert_eq!(query.api_key_type, Some(ApiKeyType::Query)); - - let ui: CreateApiKeyRequest = - serde_json::from_str(r#"{"keyName":"ui-key","roles":["reader-role"]}"#).unwrap(); - assert_eq!(ui.api_key_type, None); - assert!(ui.roles.contains("reader-role")); - - assert!( - serde_json::from_str::( - r#"{"keyName":"old-key","ingestorOnly":true}"# - ) - .is_err() - ); - } -} diff --git a/src/rbac/mod.rs b/src/rbac/mod.rs index 275360a03..cee0da54b 100644 --- a/src/rbac/mod.rs +++ b/src/rbac/mod.rs @@ -428,30 +428,3 @@ pub fn roles_to_permission(roles: Vec, tenant_id: &str) -> Vec Date: Thu, 22 Oct 2026 09:29:23 +0530 Subject: [PATCH 5/7] fix: reject non-RFC UUID variants in provided API keys --- src/apikeys.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/apikeys.rs b/src/apikeys.rs index 5a0682135..903647d4f 100644 --- a/src/apikeys.rs +++ b/src/apikeys.rs @@ -53,7 +53,10 @@ pub struct CreateApiKeyRequest { /// Require a hyphenated UUID v4, allowing either letter case. pub fn validate_api_key(value: &str) -> Result<(), ApiKeyError> { let uuid = uuid::Uuid::parse_str(value).map_err(|_| ApiKeyError::InvalidApiKey)?; - if value.len() != 36 || uuid.get_version_num() != 4 { + if value.len() != 36 + || uuid.get_version_num() != 4 + || uuid.get_variant() != uuid::Variant::RFC4122 + { return Err(ApiKeyError::InvalidApiKey); } Ok(()) From 47162e961c866dc8d310204205a5de66284478f7 Mon Sep 17 00:00:00 2001 From: Pratik Jadhav Date: Fri, 23 Oct 2026 12:01:41 +0530 Subject: [PATCH 6/7] feat: support API key name and value updates --- src/apikeys.rs | 25 +++++++-- src/handlers/http/apikeys.rs | 88 ++++++++++++++++++++++++++++++- src/handlers/http/cluster/mod.rs | 6 +++ src/handlers/http/modal/server.rs | 5 ++ 4 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/apikeys.rs b/src/apikeys.rs index 903647d4f..8dc1c1c6e 100644 --- a/src/apikeys.rs +++ b/src/apikeys.rs @@ -50,6 +50,13 @@ pub struct CreateApiKeyRequest { pub api_key_type: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PatchApiKeyRequest { + pub key_name: Option, + pub api_key: Option, +} + /// Require a hyphenated UUID v4, allowing either letter case. pub fn validate_api_key(value: &str) -> Result<(), ApiKeyError> { let uuid = uuid::Uuid::parse_str(value).map_err(|_| ApiKeyError::InvalidApiKey)?; @@ -73,6 +80,12 @@ pub enum ApiKeyError { #[error("API key value is already registered")] DuplicateApiKey, + #[error("API key update must include keyName or apiKey")] + EmptyApiKeyUpdate, + + #[error("keyName cannot be empty")] + InvalidApiKeyName, + #[error("apiKey must be a UUID v4 in 8-4-4-4-12 format")] InvalidApiKey, @@ -85,6 +98,9 @@ pub enum ApiKeyError { #[error("API key revocation could not be synced to all live nodes: {0}")] RevocationSyncFailed(String), + #[error("API key update could not be synced to all live nodes: {0}")] + UpdateSyncFailed(String), + #[error("{0}")] Storage(#[from] crate::storage::ObjectStorageError), @@ -102,11 +118,12 @@ impl actix_web::ResponseError for ApiKeyError { ApiKeyError::DuplicateKeyName(_) | ApiKeyError::DuplicateApiKey => { actix_web::http::StatusCode::CONFLICT } - ApiKeyError::InvalidApiKey | ApiKeyError::ApiKeyTypeWithRoles => { - actix_web::http::StatusCode::BAD_REQUEST - } + ApiKeyError::InvalidApiKey + | ApiKeyError::ApiKeyTypeWithRoles + | ApiKeyError::EmptyApiKeyUpdate + | ApiKeyError::InvalidApiKeyName => actix_web::http::StatusCode::BAD_REQUEST, ApiKeyError::Unauthorized(_) => actix_web::http::StatusCode::FORBIDDEN, - ApiKeyError::RevocationSyncFailed(_) => { + ApiKeyError::RevocationSyncFailed(_) | ApiKeyError::UpdateSyncFailed(_) => { actix_web::http::StatusCode::SERVICE_UNAVAILABLE } ApiKeyError::Rbac(err) => actix_web::ResponseError::status_code(err), diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs index 6507f2cca..efbf8df71 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -7,7 +7,8 @@ use ulid::Ulid; use crate::{ apikeys::{ - ApiKeyError, ApiKeyType, CreateApiKeyRequest, validate_api_key as validate_api_key_value, + ApiKeyError, ApiKeyType, CreateApiKeyRequest, PatchApiKeyRequest, + validate_api_key as validate_api_key_value, }, handlers::http::{ modal::utils::rbac_utils::{get_metadata, put_metadata}, @@ -292,6 +293,91 @@ pub async fn create_api_key( Ok(HttpResponse::Ok().json(response)) } +/// PATCH /api/prism/v1/apikeys/{key_id} +/// +/// Update a key's name or value without changing its identity or roles. +pub async fn patch_api_key( + req: HttpRequest, + path: web::Path, + web::Json(body): web::Json, +) -> Result { + let (caller_userid, tenant_id) = verify_admin(&req)?; + let key_id_str = path.into_inner(); + let key_id = + Ulid::from_string(&key_id_str).map_err(|_| ApiKeyError::KeyNotFound(key_id_str.clone()))?; + if body.key_name.is_none() && body.api_key.is_none() { + return Err(ApiKeyError::EmptyApiKeyUpdate); + } + + let guard = UPDATE_LOCK.lock().await; + let existing = collect_tenant_api_keys(&tenant_id); + let mut user = existing + .iter() + .find(|user| user.as_api_key().is_some_and(|key| key.key_id == key_id)) + .cloned() + .ok_or_else(|| ApiKeyError::KeyNotFound(key_id_str))?; + let current = user.as_api_key().expect("selected API key user"); + let key_name = body.key_name.unwrap_or_else(|| current.key_name.clone()); + let api_key_value = body.api_key.unwrap_or_else(|| current.api_key.clone()); + + if key_name.trim().is_empty() { + return Err(ApiKeyError::InvalidApiKeyName); + } + if api_key_value != current.api_key { + validate_api_key_value(&api_key_value)?; + } + if existing.iter().any(|other| { + other + .as_api_key() + .is_some_and(|key| key.key_id != key_id && key.key_name == key_name) + }) { + return Err(ApiKeyError::DuplicateKeyName(key_name.clone())); + } + if existing.iter().any(|other| { + other + .as_api_key() + .is_some_and(|key| key.key_id != key_id && key.api_key == api_key_value) + }) { + return Err(ApiKeyError::DuplicateApiKey); + } + + let changed = key_name != current.key_name || api_key_value != current.api_key; + if changed { + let key = match &mut user.ty { + UserType::ApiKey(key) => key, + _ => unreachable!("selected API key user"), + }; + key.key_name.clone_from(&key_name); + key.api_key.clone_from(&api_key_value); + key.modified_at = Utc::now(); + + let mut metadata = get_metadata(&tenant_id).await?; + let stored = metadata + .users + .iter_mut() + .find(|stored| stored.userid() == user.userid()) + .ok_or_else(|| ApiKeyError::KeyNotFound(key_id.to_string()))?; + *stored = user.clone(); + put_metadata(&metadata, &tenant_id).await?; + Users.put_user(user.clone()); + } + drop(guard); + + // A retry also re-sends an already applied update to any lagging nodes. + crate::handlers::http::cluster::sync_user_creation( + &req, + user.clone(), + &None, + &tenant_id, + &caller_userid, + ) + .await + .map_err(|error: RBACError| ApiKeyError::UpdateSyncFailed(error.to_string()))?; + + let response = ApiKeyResponse::try_from(&user)?; + Ok(HttpResponse::Ok().json(response)) +} + /// DELETE /api/prism/v1/apikeys/{key_id} /// /// Delete an API key by key_id. Only admins can delete keys. diff --git a/src/handlers/http/cluster/mod.rs b/src/handlers/http/cluster/mod.rs index 8ecc5538f..99d354ffc 100644 --- a/src/handlers/http/cluster/mod.rs +++ b/src/handlers/http/cluster/mod.rs @@ -618,11 +618,17 @@ pub async fn sync_user_creation( })?; if !res.status().is_success() { + let status = res.status(); error!( "failed to forward request to node: {}\nResponse Returned: {:?}", node.domain_name, res.text().await ); + return Err(RBACError::Anyhow(anyhow::anyhow!( + "user sync to {} failed with HTTP {}", + node.domain_name, + status + ))); } Ok(()) diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index 7b58e91da..6d1fee80a 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -270,6 +270,11 @@ impl Server { .service( resource("/{key_id}") .route(web::get().to(apikeys::get_api_key).authorize(Action::All)) + .route( + web::patch() + .to(apikeys::patch_api_key) + .authorize(Action::All), + ) .route( web::delete() .to(apikeys::delete_api_key) From d248171ffa4f7074544ec759ac775ca3f349f04a Mon Sep 17 00:00:00 2001 From: Pratik Jadhav Date: Fri, 23 Oct 2026 13:27:55 +0530 Subject: [PATCH 7/7] fix: use clone_from for API key metadata update --- 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 efbf8df71..71c3871a3 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -357,7 +357,7 @@ pub async fn patch_api_key( .iter_mut() .find(|stored| stored.userid() == user.userid()) .ok_or_else(|| ApiKeyError::KeyNotFound(key_id.to_string()))?; - *stored = user.clone(); + stored.clone_from(&user); put_metadata(&metadata, &tenant_id).await?; Users.put_user(user.clone()); }