diff --git a/src/apikeys.rs b/src/apikeys.rs index 49dc980c5..d139b351a 100644 --- a/src/apikeys.rs +++ b/src/apikeys.rs @@ -49,6 +49,9 @@ pub enum ApiKeyError { #[error("Unauthorized: {0}")] Unauthorized(String), + #[error("API key revocation could not be synced to all live nodes: {0}")] + RevocationSyncFailed(String), + #[error("{0}")] Storage(#[from] crate::storage::ObjectStorageError), @@ -65,6 +68,9 @@ impl actix_web::ResponseError for ApiKeyError { ApiKeyError::KeyNotFound(_) => actix_web::http::StatusCode::NOT_FOUND, ApiKeyError::DuplicateKeyName(_) => actix_web::http::StatusCode::CONFLICT, ApiKeyError::Unauthorized(_) => actix_web::http::StatusCode::FORBIDDEN, + ApiKeyError::RevocationSyncFailed(_) => { + actix_web::http::StatusCode::SERVICE_UNAVAILABLE + } ApiKeyError::Rbac(err) => actix_web::ResponseError::status_code(err), ApiKeyError::Storage(_) | ApiKeyError::Anyhow(_) => { actix_web::http::StatusCode::INTERNAL_SERVER_ERROR diff --git a/src/handlers/http/apikeys.rs b/src/handlers/http/apikeys.rs new file mode 100644 index 000000000..7cf4fa99d --- /dev/null +++ b/src/handlers/http/apikeys.rs @@ -0,0 +1,334 @@ +use std::collections::HashSet; + +use actix_web::{HttpRequest, HttpResponse, Responder, web}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +use crate::{ + apikeys::{ApiKeyError, CreateApiKeyRequest}, + handlers::http::{ + modal::utils::rbac_utils::{get_metadata, put_metadata}, + rbac::{RBACError, UPDATE_LOCK}, + }, + parseable::DEFAULT_TENANT, + rbac::{ + Users, + map::{roles, users}, + user::{User, UserType}, + }, + utils::{get_user_and_tenant_from_request, is_admin}, +}; + +/// Verify the caller is an admin of their tenant or a super-admin. +fn verify_admin(req: &HttpRequest) -> Result<(String, Option), ApiKeyError> { + let (userid, tenant_id) = get_user_and_tenant_from_request(req) + .map_err(|_| ApiKeyError::Unauthorized("Missing user identity".into()))?; + + if is_admin(req).map_err(|err| ApiKeyError::Unauthorized(err.to_string()))? { + return Ok((userid, tenant_id)); + } + + Err(ApiKeyError::Unauthorized( + "Only admins can manage API keys".into(), + )) +} + +/// Generate a fresh, opaque API key value as a UUID v4 string +/// (matches the original api key format used by CreateApiKeyRequest clients). +fn generate_api_key_value() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Serialisable shape returned by create / get. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ApiKeyResponse<'a> { + key_id: Ulid, + api_key: &'a str, + key_name: &'a str, + roles: &'a HashSet, + created_by: &'a str, + created_at: DateTime, + modified_at: DateTime, +} + +impl<'a> TryFrom<&'a User> for ApiKeyResponse<'a> { + type Error = ApiKeyError; + + fn try_from(user: &'a User) -> Result { + let api_key = user + .as_api_key() + .ok_or_else(|| ApiKeyError::KeyNotFound(user.userid().to_string()))?; + Ok(Self { + key_id: api_key.key_id, + api_key: &api_key.api_key, + key_name: &api_key.key_name, + roles: &user.roles, + created_by: &api_key.created_by, + created_at: api_key.created_at, + modified_at: api_key.modified_at, + }) + } +} + +/// Serialisable shape returned by list (api_key value masked). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ApiKeyListEntry { + key_id: Ulid, + api_key: String, + key_name: String, + roles: HashSet, + created_by: String, + created_at: DateTime, + modified_at: DateTime, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ValidateApiKeyResponse { + valid: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateApiKeyRequest { + api_key: String, +} + +impl ApiKeyListEntry { + fn from_user(user: &User) -> Option { + let api_key = user.as_api_key()?; + let masked = if api_key.api_key.len() >= 4 { + let last4 = &api_key.api_key[api_key.api_key.len() - 4..]; + format!("****{last4}") + } else { + "****".to_string() + }; + Some(Self { + key_id: api_key.key_id, + api_key: masked, + key_name: api_key.key_name.clone(), + roles: user.roles.clone(), + created_by: api_key.created_by.clone(), + created_at: api_key.created_at, + modified_at: api_key.modified_at, + }) + } +} + +fn tenant_or_default(tenant: &Option) -> &str { + tenant.as_deref().unwrap_or(DEFAULT_TENANT) +} + +/// Collect all API-key-backed users for a tenant into an owned list. +/// Read lock on the users map is held only for the duration of this fn. +fn collect_tenant_api_keys(tenant_id: &Option) -> Vec { + let users_guard = users(); + let tenant = tenant_or_default(tenant_id); + users_guard + .get(tenant) + .into_iter() + .flat_map(|m| m.values()) + .filter(|u| matches!(u.ty, UserType::ApiKey(_))) + .cloned() + .collect() +} + +/// Validate that every role in the request exists in the tenant's role map. +/// Mirrors the check performed by the native-user CRUD endpoint. +fn validate_roles( + role_names: &HashSet, + tenant_id: &Option, +) -> Result<(), ApiKeyError> { + let tenant = tenant_or_default(tenant_id); + let tenant_roles = roles(); + let missing: Vec = role_names + .iter() + .filter(|r| { + tenant_roles + .get(tenant) + .map(|m| !m.contains_key(*r)) + .unwrap_or(true) + }) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(ApiKeyError::Rbac(RBACError::RolesDoNotExist(missing))) + } +} + +/// POST /api/prism/v1/apikeys +/// +/// Create a new API key. Only admins can create keys. +pub async fn create_api_key( + req: HttpRequest, + web::Json(body): web::Json, +) -> Result { + let (created_by, tenant_id) = verify_admin(&req)?; + + 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)?; + + // Duplicate key-name detection inside the same tenant. + let existing = collect_tenant_api_keys(&tenant_id); + if existing + .iter() + .filter_map(|u| u.as_api_key()) + .any(|k| k.key_name == body.key_name) + { + return Err(ApiKeyError::DuplicateKeyName(body.key_name)); + } + + let key_id = Ulid::new(); + let api_key_value = generate_api_key_value(); + let user = User::new_api_key( + key_id, + api_key_value, + body.key_name, + body.roles, + created_by, + tenant_id.clone(), + ); + + // Persist the new user in parseable.json and push into memory on this node. + let mut metadata = get_metadata(&tenant_id).await?; + metadata.users.push(user.clone()); + put_metadata(&metadata, &tenant_id).await?; + Users.put_user(user.clone()); + + // Drop the lock once local state is durable; the cluster fan-out below + // can be slow and must not block other create/delete requests. + drop(guard); + + // Best-effort sync to other live nodes (reuses the standard user sync). + let caller_userid = user + .as_api_key() + .map(|k| k.created_by.clone()) + .unwrap_or_default(); + if let Err(e) = crate::handlers::http::cluster::sync_user_creation( + &req, + user.clone(), + &None, + &tenant_id, + &caller_userid, + ) + .await + { + tracing::error!("Failed to sync API-key user creation: {e}"); + } + + 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. +pub async fn delete_api_key( + req: HttpRequest, + path: web::Path, +) -> 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()))?; + + let guard = UPDATE_LOCK.lock().await; + + // Find the user entry for this api key. + let (userid, key_name) = collect_tenant_api_keys(&tenant_id) + .into_iter() + .find_map(|u| { + u.as_api_key() + .filter(|k| k.key_id == key_id) + .map(|k| (u.userid().to_string(), k.key_name.clone())) + }) + .ok_or_else(|| ApiKeyError::KeyNotFound(key_id.to_string()))?; + + // Make cluster revocation part of the success contract. If this fails, we + // leave the local key intact so the caller can retry the same revocation. + if let Err(e) = crate::handlers::http::cluster::sync_user_deletion_with_ingestors( + &req, + &userid, + &tenant_id, + &caller_userid, + ) + .await + { + tracing::error!("Failed to sync API-key user deletion: {e}"); + return Err(ApiKeyError::RevocationSyncFailed(e.to_string())); + } + + // Remove from parseable.json. + let mut metadata = get_metadata(&tenant_id).await?; + metadata.users.retain(|u| u.userid() != userid.as_str()); + put_metadata(&metadata, &tenant_id).await?; + + // Remove from in-memory map on this node. + Users.delete_user(&userid, &tenant_id); + + drop(guard); + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "keyId": key_id, + "keyName": key_name, + "message": "API key deleted successfully", + }))) +} + +/// GET /api/prism/v1/apikeys +/// +/// List all API keys (masked). Only admins can list keys. +pub async fn list_api_keys(req: HttpRequest) -> Result { + let (_, tenant_id) = verify_admin(&req)?; + let entries: Vec = collect_tenant_api_keys(&tenant_id) + .iter() + .filter_map(ApiKeyListEntry::from_user) + .collect(); + Ok(HttpResponse::Ok().json(entries)) +} + +/// POST /api/prism/v1/apikeys/validate +/// +/// Validate whether an API key exists in the caller's tenant. +pub async fn validate_api_key( + req: HttpRequest, + web::Json(body): web::Json, +) -> Result { + let (_, tenant_id) = get_user_and_tenant_from_request(&req) + .map_err(|_| ApiKeyError::Unauthorized("Missing user identity".into()))?; + let valid = collect_tenant_api_keys(&tenant_id) + .iter() + .filter_map(|u| u.as_api_key()) + .any(|k| k.api_key == body.api_key); + + Ok(HttpResponse::Ok().json(ValidateApiKeyResponse { valid })) +} + +/// GET /api/prism/v1/apikeys/{key_id} +/// +/// Get a specific API key (full key visible). Only admins can get keys. +pub async fn get_api_key( + req: HttpRequest, + path: web::Path, +) -> Result { + let (_, 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()))?; + + let user = collect_tenant_api_keys(&tenant_id) + .into_iter() + .find(|u| u.as_api_key().map(|k| k.key_id == key_id).unwrap_or(false)) + .ok_or_else(|| ApiKeyError::KeyNotFound(key_id.to_string()))?; + + let response = ApiKeyResponse::try_from(&user)?; + Ok(HttpResponse::Ok().json(response)) +} diff --git a/src/handlers/http/mod.rs b/src/handlers/http/mod.rs index 86c2be877..25962701d 100644 --- a/src/handlers/http/mod.rs +++ b/src/handlers/http/mod.rs @@ -30,6 +30,7 @@ use self::query::Query; pub mod about; pub mod alerts; +pub mod apikeys; pub mod cluster; pub mod correlation; pub mod demo_data; diff --git a/src/handlers/http/modal/query_server.rs b/src/handlers/http/modal/query_server.rs index 3da7d6e53..828ef1585 100644 --- a/src/handlers/http/modal/query_server.rs +++ b/src/handlers/http/modal/query_server.rs @@ -83,6 +83,7 @@ impl ParseableServer for QueryServer { .service(Server::get_prism_home()) .service(Server::get_prism_logstream()) .service(Server::get_prism_datasets()) + .service(Server::get_apikeys_webscope()) .service(Server::get_dataset_stats_webscope()), ) .service(Server::get_generated()); diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index 06703ad7d..cae7dd3aa 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -22,6 +22,7 @@ use crate::analytics; use crate::handlers; use crate::handlers::http::about; use crate::handlers::http::alerts; +use crate::handlers::http::apikeys; use crate::handlers::http::base_path; use crate::handlers::http::demo_data::get_demo_data; use crate::handlers::http::health_check; @@ -106,6 +107,7 @@ impl ParseableServer for Server { .service(Server::get_prism_home()) .service(Server::get_prism_logstream()) .service(Server::get_prism_datasets()) + .service(Server::get_apikeys_webscope()) .service(Self::get_dataset_stats_webscope()), ) .service(Self::get_ingest_otel_factory()) @@ -209,6 +211,35 @@ impl Server { ) } + pub fn get_apikeys_webscope() -> Scope { + web::scope("/apikeys") + .service( + resource("") + .route( + web::post() + .to(apikeys::create_api_key) + .authorize(Action::All), + ) + .route(web::get().to(apikeys::list_api_keys).authorize(Action::All)), + ) + .service( + resource("/validate").route( + web::post() + .to(apikeys::validate_api_key) + .authorize(Action::All), + ), + ) + .service( + resource("/{key_id}") + .route(web::get().to(apikeys::get_api_key).authorize(Action::All)) + .route( + web::delete() + .to(apikeys::delete_api_key) + .authorize(Action::All), + ), + ) + } + pub fn get_demo_data_webscope() -> Scope { web::scope("/demodata").service(web::resource("").route(web::get().to(get_demo_data))) }