Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
154 changes: 130 additions & 24 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,
map::{roles, users},
GLOBAL_INGESTOR_ROLE, GLOBAL_QUERIER_ROLE, Users,
map::users,
role::model::{DefaultPrivilege, Role},
role_exists, role_for,
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 Expand Up @@ -334,20 +444,16 @@ pub async fn list_api_keys(
) -> Result<impl Responder, ApiKeyError> {
let (_, tenant_id) = verify_admin(&req)?;
let tenant = tenant_or_default(&tenant_id);
let tenant_roles = roles();
let entries: Vec<ApiKeyListEntry> = collect_tenant_api_keys(&tenant_id)
.iter()
.filter(|user| {
query.priviledge.as_ref().is_none_or(|privileges| {
user.roles.iter().any(|role_name| {
tenant_roles
.get(tenant)
.and_then(|roles| roles.get(role_name))
.is_some_and(|role| {
privileges
.iter()
.any(|privilege| role_has_privilege(role, privilege))
})
role_for(tenant, role_name).is_some_and(|role| {
privileges
.iter()
.any(|privilege| role_has_privilege(&role, privilege))
})
})
})
})
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
)));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

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
27 changes: 5 additions & 22 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,29 +149,16 @@ 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());
}
});

if !non_existent_roles.is_empty() {
return Err(RBACError::RolesDoNotExist(non_existent_roles));
}

// check that user actually has these roles
let user_roles: HashSet<String> = HashSet::from_iter(Users.get_role(&userid, &tenant_id));
let roles_not_with_user: HashSet<String> =
HashSet::from_iter(roles_to_remove.difference(&user_roles).cloned());

if !roles_not_with_user.is_empty() {
return Err(RBACError::RolesNotAssigned(Vec::from_iter(
roles_not_with_user,
)));
}

// update parseable.json in staging first
let mut metadata = get_metadata(&tenant_id).await?;
if let Some(user) = metadata
Expand Down
Loading
Loading