diff --git a/src/apikeys.rs b/src/apikeys.rs index d139b351a..8dc1c1c6e 100644 --- a/src/apikeys.rs +++ b/src/apikeys.rs @@ -30,12 +30,43 @@ 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). +/// `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")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CreateApiKeyRequest { pub key_name: String, #[serde(default)] pub roles: HashSet, + pub api_key: Option, + /// Omit for the existing UI/API flow that assigns explicit roles. + 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)?; + if value.len() != 36 + || uuid.get_version_num() != 4 + || uuid.get_variant() != uuid::Variant::RFC4122 + { + return Err(ApiKeyError::InvalidApiKey); + } + Ok(()) } #[derive(Debug, thiserror::Error)] @@ -46,12 +77,30 @@ pub enum ApiKeyError { #[error("Duplicate key name: {0}")] DuplicateKeyName(String), + #[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, + + #[error("apiKeyType cannot be combined with roles")] + ApiKeyTypeWithRoles, + #[error("Unauthorized: {0}")] Unauthorized(String), #[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), @@ -66,9 +115,15 @@ 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::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 333831ee6..71c3871a3 100644 --- a/src/handlers/http/apikeys.rs +++ b/src/handlers/http/apikeys.rs @@ -6,14 +6,17 @@ use serde::{Deserialize, Deserializer, Serialize, de}; use ulid::Ulid; use crate::{ - apikeys::{ApiKeyError, CreateApiKeyRequest}, + apikeys::{ + ApiKeyError, ApiKeyType, CreateApiKeyRequest, PatchApiKeyRequest, + 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, GLOBAL_QUERIER_ROLE, Users, map::{roles, users}, role::model::{DefaultPrivilege, Role}, user::{User, UserType}, @@ -206,9 +209,25 @@ 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)?; + // 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 + } + }; // Duplicate key-name detection inside the same tenant. let existing = collect_tenant_api_keys(&tenant_id); @@ -221,12 +240,24 @@ 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(api_key) = body.api_key { + validate_api_key_value(&api_key)?; + 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, body.key_name, - body.roles, + assigned_roles, created_by, tenant_id.clone(), ); @@ -262,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.clone_from(&user); + 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) diff --git a/src/rbac/mod.rs b/src/rbac/mod.rs index 0e7dda57d..cee0da54b 100644 --- a/src/rbac/mod.rs +++ b/src/rbac/mod.rs @@ -41,10 +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 { @@ -397,6 +402,19 @@ 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; + } + if role == GLOBAL_QUERIER_ROLE { + perms.extend( + RoleBuilder::from(&DefaultPrivilege::Reader { + resource: Some(ParseableResourceType::Stream("*".to_string())), + }) + .build(), + ); + continue; + } let role_map = &map::roles(); if let Some(roles) = role_map.get(tenant_id) && let Some(privilege_list) = roles.get(role)