Skip to content
Open
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
130 changes: 123 additions & 7 deletions src/handlers/http/apikeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as 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 @@ -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<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());
}
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.
Expand Down
6 changes: 6 additions & 0 deletions src/handlers/http/cluster/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
5 changes: 5 additions & 0 deletions src/handlers/http/modal/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 19 additions & 1 deletion src/rbac/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -397,6 +402,19 @@ pub struct UsersPrism {
pub fn roles_to_permission(roles: Vec<String>, tenant_id: &str) -> Vec<Permission> {
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())),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
.build(),
);
continue;
}
let role_map = &map::roles();
if let Some(roles) = role_map.get(tenant_id)
&& let Some(privilege_list) = roles.get(role)
Expand Down
Loading