Skip to content
61 changes: 58 additions & 3 deletions src/apikeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub api_key: Option<String>,
/// Omit for the existing UI/API flow that assigns explicit roles.
pub api_key_type: Option<ApiKeyType>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PatchApiKeyRequest {
pub key_name: Option<String>,
pub api_key: Option<String>,
}

/// 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)]
Expand All @@ -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),

Expand All @@ -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),
Expand Down
138 changes: 124 additions & 14 deletions src/handlers/http/apikeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ 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},
role_exists,
user::{User, UserType},
},
utils::{get_user_and_tenant_from_request, is_admin},
Expand Down Expand Up @@ -183,15 +187,9 @@ fn validate_roles(
tenant_id: &Option<String>,
) -> Result<(), ApiKeyError> {
let tenant = tenant_or_default(tenant_id);
let tenant_roles = roles();
let missing: Vec<String> = role_names
.iter()
.filter(|r| {
tenant_roles
.get(tenant)
.map(|m| !m.contains_key(*r))
.unwrap_or(true)
})
.filter(|r| !role_exists(tenant, r))
.cloned()
.collect();
if missing.is_empty() {
Expand All @@ -212,9 +210,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);
Expand All @@ -227,12 +241,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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let user = User::new_api_key(
key_id,
api_key_value,
body.key_name,
body.roles,
assigned_roles,
created_by,
tenant_id.clone(),
);
Expand Down Expand Up @@ -268,6 +294,90 @@ 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<String>,
web::Json(body): web::Json<PatchApiKeyRequest>,
) -> Result<impl Responder, ApiKeyError> {
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());
}
// 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()))?;
drop(guard);

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.
Expand Down
12 changes: 12 additions & 0 deletions src/handlers/http/cluster/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,11 +507,17 @@ pub async fn sync_users_with_roles_with_ingestors(
})?;

if !res.status().is_success() {
let status = res.status();
error!(
"failed to forward request to ingestor: {}\nResponse Returned: {:?}",
ingestor.domain_name,
res.text().await
);
return Err(RBACError::Anyhow(anyhow::anyhow!(
"role sync to {} failed with HTTP {}",
ingestor.domain_name,
status
)));
}

Ok(())
Expand Down Expand Up @@ -618,11 +624,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(())
Expand Down
16 changes: 5 additions & 11 deletions src/handlers/http/modal/ingest/ingestor_rbac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ use crate::{
},
parseable::DEFAULT_TENANT,
rbac::{
Users,
map::roles,
Users, role_exists,
user::{self, User as ParseableUser},
},
storage,
Expand Down Expand Up @@ -107,10 +106,7 @@ pub async fn add_roles_to_user(
let mut non_existent_roles = Vec::new();
roles_to_add.iter().for_each(|r| {
let tenant = tenant_id.as_deref().unwrap_or(DEFAULT_TENANT);
let role_exists = roles()
.get(tenant)
.is_some_and(|tenant_roles| tenant_roles.contains_key(r));
if !role_exists {
if !role_exists(tenant, r) {
non_existent_roles.push(r.clone());
}
});
Expand Down Expand Up @@ -153,11 +149,9 @@ pub async fn remove_roles_from_user(

// check if all roles exist
let mut non_existent_roles = Vec::new();
roles_to_remove.iter().for_each(|r| {
if let Some(tenant_roles) = roles().get(tenant_id.as_deref().unwrap_or(DEFAULT_TENANT))
&& tenant_roles.get(r).is_none()
{
non_existent_roles.push(r.clone());
roles_to_remove.iter().for_each(|role| {
if !role_exists(tenant_id.as_deref().unwrap_or(DEFAULT_TENANT), role) {
non_existent_roles.push(role.clone());
}
});

Expand Down
11 changes: 10 additions & 1 deletion src/handlers/http/modal/ingest/ingestor_role.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ use crate::{
role::RoleError,
},
parseable::DEFAULT_TENANT,
rbac::map::{mut_roles, mut_sessions, read_user_groups, users},
rbac::{
is_system_role,
map::{mut_roles, mut_sessions, read_user_groups, users},
},
storage,
utils::get_tenant_id_from_request,
};
Expand All @@ -42,6 +45,9 @@ pub async fn put(
Json(sync_req): Json<SyncRole>,
) -> Result<impl Responder, RoleError> {
let name = name.into_inner();
if is_system_role(&name) {
return Err(RoleError::SystemRolePrivilegesLocked(name));
}
let req_tenant_id = get_tenant_id_from_request(&req);
let req_tenant = req_tenant_id.as_deref().unwrap_or(DEFAULT_TENANT);
if req_tenant.ne(DEFAULT_TENANT) && (req_tenant_id.ne(&sync_req.tenant_id)) {
Expand Down Expand Up @@ -100,6 +106,9 @@ pub async fn delete(
name: web::Path<String>,
) -> Result<impl Responder, RoleError> {
let name = name.into_inner();
if is_system_role(&name) {
return Err(RoleError::SystemRoleDeletion(name));
}
let req_tenant_id = get_tenant_id_from_request(&req);

let mut metadata = get_metadata(&req_tenant_id).await?;
Expand Down
Loading
Loading