diff --git a/src/apikeys.rs b/src/apikeys.rs index 38da68ef1..49dc980c5 100644 --- a/src/apikeys.rs +++ b/src/apikeys.rs @@ -16,298 +16,26 @@ * */ -use std::collections::HashMap; +//! API key primitives. +//! +//! API keys are persisted as a third `UserType` variant (`UserType::ApiKey`) +//! inside `parseable.json`, alongside native and OAuth users. The backing +//! user's permissions are resolved from the `roles` assigned to it (same +//! mechanism as native and OAuth users). -use chrono::{DateTime, Utc}; -use once_cell::sync::Lazy; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; -use ulid::Ulid; +use std::collections::HashSet; -use crate::{ - metastore::metastore_traits::MetastoreObject, - parseable::{DEFAULT_TENANT, PARSEABLE}, - storage::object_storage::apikey_json_path, -}; +use serde::Deserialize; -pub static API_KEYS: Lazy = Lazy::new(|| ApiKeyStore { - keys: RwLock::new(HashMap::new()), -}); - -#[derive(Debug)] -pub struct ApiKeyStore { - pub keys: RwLock>>, -} - -/// Type of API key, determining how it can be used. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum KeyType { - /// Used as a substitute for basic auth on ingestion endpoints - Ingestion, - /// Used as a substitute for basic auth on query endpoints (global query access) - Query, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiKey { - pub key_id: Ulid, - pub api_key: String, - pub key_name: String, - #[serde(default = "default_key_type")] - pub key_type: KeyType, - pub created_by: String, - pub created_at: DateTime, - pub modified_at: DateTime, - #[serde(default)] - pub tenant: Option, -} - -fn default_key_type() -> KeyType { - KeyType::Ingestion -} - -/// Request body for creating a new API key +/// 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). #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateApiKeyRequest { pub key_name: String, - #[serde(default = "default_key_type")] - pub key_type: KeyType, -} - -/// Response for list keys (api_key masked to last 4 chars) -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiKeyListEntry { - pub key_id: Ulid, - pub api_key: String, - pub key_name: String, - pub key_type: KeyType, - pub created_by: String, - pub created_at: DateTime, - pub modified_at: DateTime, -} - -impl ApiKey { - pub fn new( - key_name: String, - key_type: KeyType, - created_by: String, - tenant: Option, - ) -> Self { - let now = Utc::now(); - Self { - key_id: Ulid::new(), - api_key: uuid::Uuid::new_v4().to_string(), - key_name, - key_type, - created_by, - created_at: now, - modified_at: now, - tenant, - } - } - - pub fn to_list_entry(&self) -> ApiKeyListEntry { - let masked = if self.api_key.len() >= 4 { - let last4 = &self.api_key[self.api_key.len() - 4..]; - format!("****{last4}") - } else { - "****".to_string() - }; - ApiKeyListEntry { - key_id: self.key_id, - api_key: masked, - key_name: self.key_name.clone(), - key_type: self.key_type, - created_by: self.created_by.clone(), - created_at: self.created_at, - modified_at: self.modified_at, - } - } -} - -impl MetastoreObject for ApiKey { - fn get_object_path(&self) -> String { - apikey_json_path(&self.key_id, &self.tenant).to_string() - } - - fn get_object_id(&self) -> String { - self.key_id.to_string() - } -} - -impl ApiKeyStore { - /// Load API keys from object store into memory - pub async fn load(&self) -> anyhow::Result<()> { - let api_keys = PARSEABLE.metastore.get_api_keys().await?; - let mut map = self.keys.write().await; - for (tenant_id, keys) in api_keys { - let inner = keys - .into_iter() - .map(|mut k| { - k.tenant = if tenant_id == DEFAULT_TENANT { - None - } else { - Some(tenant_id.clone()) - }; - (k.key_id, k) - }) - .collect(); - map.insert(tenant_id, inner); - } - Ok(()) - } - - /// Create a new API key - pub async fn create(&self, api_key: ApiKey) -> Result<(), ApiKeyError> { - let tenant = api_key.tenant.as_deref().unwrap_or(DEFAULT_TENANT); - let key_id = api_key.key_id; - - // Check duplicate name and reserve the slot under the write lock, - // then drop the lock before the async metastore call so we don't - // hold a global lock across an await. - { - let mut map = self.keys.write().await; - if let Some(tenant_keys) = map.get(tenant) - && tenant_keys.values().any(|k| k.key_name == api_key.key_name) - { - return Err(ApiKeyError::DuplicateKeyName(api_key.key_name)); - } - map.entry(tenant.to_owned()) - .or_default() - .insert(key_id, api_key.clone()); - } - - // Persist to storage without holding the lock. On failure, remove - // the reservation so stale entries don't linger in memory. - if let Err(e) = PARSEABLE - .metastore - .put_api_key(&api_key, &api_key.tenant) - .await - { - let mut map = self.keys.write().await; - if let Some(tenant_keys) = map.get_mut(tenant) { - tenant_keys.remove(&key_id); - } - return Err(e.into()); - } - - Ok(()) - } - - /// Delete an API key by key_id - pub async fn delete( - &self, - key_id: &Ulid, - tenant_id: &Option, - ) -> Result { - let tenant = tenant_id.as_deref().unwrap_or(DEFAULT_TENANT); - - // Read the key first without removing - let api_key = { - let map = self.keys.read().await; - let tenant_keys = map - .get(tenant) - .ok_or_else(|| ApiKeyError::KeyNotFound(key_id.to_string()))?; - tenant_keys - .get(key_id) - .cloned() - .ok_or_else(|| ApiKeyError::KeyNotFound(key_id.to_string()))? - }; - - // Delete from storage first - PARSEABLE - .metastore - .delete_api_key(&api_key, tenant_id) - .await?; - - // Remove from memory only after successful storage deletion - { - let mut map = self.keys.write().await; - if let Some(tenant_keys) = map.get_mut(tenant) { - tenant_keys.remove(key_id); - } - } - - Ok(api_key) - } - - /// List all API keys for a tenant (returns masked entries) - pub async fn list( - &self, - tenant_id: &Option, - ) -> Result, ApiKeyError> { - let tenant = tenant_id.as_deref().unwrap_or(DEFAULT_TENANT); - let map = self.keys.read().await; - let entries = if let Some(tenant_keys) = map.get(tenant) { - tenant_keys.values().map(|k| k.to_list_entry()).collect() - } else { - vec![] - }; - Ok(entries) - } - - /// Get a specific API key by key_id (returns full key) - pub async fn get( - &self, - key_id: &Ulid, - tenant_id: &Option, - ) -> Result { - let tenant = tenant_id.as_deref().unwrap_or(DEFAULT_TENANT); - let map = self.keys.read().await; - let tenant_keys = map - .get(tenant) - .ok_or_else(|| ApiKeyError::KeyNotFound(key_id.to_string()))?; - tenant_keys - .get(key_id) - .cloned() - .ok_or_else(|| ApiKeyError::KeyNotFound(key_id.to_string())) - } - - /// Validate an API key against a required key type. Returns true if the - /// key is valid AND its type matches the required type. - /// For multi-tenant: checks the key belongs to the specified tenant. - /// For single-tenant: checks the key exists globally. - pub async fn validate_key( - &self, - api_key_value: &str, - tenant_id: &Option, - required_type: KeyType, - ) -> bool { - let map = self.keys.read().await; - let tenant = tenant_id.as_deref().unwrap_or(DEFAULT_TENANT); - if let Some(tenant_keys) = map.get(tenant) { - return tenant_keys - .values() - .any(|k| k.api_key == api_key_value && k.key_type == required_type); - } - false - } - - /// Insert an API key directly into memory (used for sync from prism) - pub async fn sync_put(&self, api_key: ApiKey) { - let tenant = api_key - .tenant - .as_deref() - .unwrap_or(DEFAULT_TENANT) - .to_owned(); - let mut map = self.keys.write().await; - map.entry(tenant) - .or_default() - .insert(api_key.key_id, api_key); - } - - /// Remove an API key from memory (used for sync from prism) - pub async fn sync_delete(&self, key_id: &Ulid, tenant_id: &Option) { - let tenant = tenant_id.as_deref().unwrap_or(DEFAULT_TENANT); - let mut map = self.keys.write().await; - if let Some(tenant_keys) = map.get_mut(tenant) { - tenant_keys.remove(key_id); - } - } + #[serde(default)] + pub roles: HashSet, } #[derive(Debug, thiserror::Error)] @@ -322,10 +50,13 @@ pub enum ApiKeyError { Unauthorized(String), #[error("{0}")] - MetastoreError(#[from] crate::metastore::MetastoreError), + Storage(#[from] crate::storage::ObjectStorageError), + + #[error("{0}")] + Rbac(#[from] crate::handlers::http::rbac::RBACError), #[error("{0}")] - AnyhowError(#[from] anyhow::Error), + Anyhow(#[from] anyhow::Error), } impl actix_web::ResponseError for ApiKeyError { @@ -334,7 +65,8 @@ 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::MetastoreError(_) | ApiKeyError::AnyhowError(_) => { + 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/middleware.rs b/src/handlers/http/middleware.rs index ef23bcecb..73ad3fca4 100644 --- a/src/handlers/http/middleware.rs +++ b/src/handlers/http/middleware.rs @@ -32,7 +32,6 @@ use once_cell::sync::OnceCell; use ulid::Ulid; use crate::{ - apikeys::API_KEYS, handlers::{ AUTHORIZATION_KEY, KINESIS_COMMON_ATTRIBUTES_KEY, LOG_SOURCE_KEY, LOG_SOURCE_KINESIS, STREAM_NAME_HEADER_KEY, TENANT_ID, @@ -44,6 +43,7 @@ use crate::{ EXPIRY_DURATION, map::{SessionKey, mut_sessions, mut_users, sessions, users}, roles_to_permission, user, + user::UserType, }, tenants::TENANT_METADATA, utils::{get_user_and_tenant_from_request, get_user_from_request}, @@ -156,7 +156,7 @@ where // Capture the incoming tenant header value before `get_user_and_tenant` // potentially mutates it, so the API-key branch below sees the original // client-supplied value. - let tenant_id_before = req + let tenant_id = req .headers() .get(TENANT_ID) .and_then(|v| v.to_str().ok()) @@ -166,77 +166,36 @@ where let mut header_error = None; let user_and_tenant_id = get_user_and_tenant(&self.action, &mut req, &mut header_error); - // If X-API-KEY header is present and the action supports API key auth, - // short-circuit the normal auth flow. - if let Some(api_key) = extract_api_key(&req) - && let Some(required_type) = api_key_type_for_action(&self.action) - { - struct SessionCleanupGuard(Option); - impl Drop for SessionCleanupGuard { - fn drop(&mut self) { - if let Some(sid) = self.0 { - mut_sessions().remove_session(&SessionKey::SessionId(sid)); - } - } - } - - let tenant_id = tenant_id_before; - let suspension = check_suspension_for_tenant(tenant_id.as_deref(), self.action); - - // For Query keys we pre-allocate a session id and stash the - // SessionKey in req.extensions so the downstream handler can find - // it via extract_session_key_from_req. The actual session - // registration (track_new) is deferred until after the API key - // is validated, so failed requests don't mutate the shared - // sessions map. - let query_session_id = if required_type == crate::apikeys::KeyType::Query { - let session_id = Ulid::new(); - req.extensions_mut() - .insert(SessionKey::SessionId(session_id)); - Some(session_id) - } else { - None - }; - - let fut = self.service.call(req); - - return Box::pin(async move { - // Guard starts with None — only set to Some(session_id) once - // we've actually called track_new, so invalid/failed requests - // don't trigger a spurious remove_session. - let mut guard = SessionCleanupGuard(None); - - if let Some(err) = header_error { - return Err(err); - } - if let rbac::Response::Suspended(msg) = suspension { - return Err(ErrorBadRequest(msg)); - } - - if !API_KEYS - .validate_key(&api_key, &tenant_id, required_type) - .await - { - return Err(ErrorUnauthorized("Invalid API key")); - } - - // Key validated — register the session in the shared - // sessions map. Arm the cleanup guard so the session is - // removed when this request completes. - if let Some(session_id) = query_session_id { + // If an X-API-KEY header is present, resolve it to the backing + // `UserType::ApiKey` user and register an ephemeral session whose + // permissions are derived from the user's assigned roles (same flow + // as native/OAuth users). The request then falls through to the + // normal auth check, which consults the session permissions. + let api_key_session_id = if let Some(api_key) = extract_api_key(&req) { + let tenant_id: Option = tenant_id.clone(); + match find_api_key_user(&api_key, &tenant_id) { + Some(user) => { + let tenant = user.tenant.as_deref().unwrap_or(DEFAULT_TENANT); + let permissions = roles_to_permission(user.roles(), tenant); + let session_id = Ulid::new(); + let session_key = SessionKey::SessionId(session_id); mut_sessions().track_new( - format!("api-key:{session_id}"), - SessionKey::SessionId(session_id), + user.userid().to_owned(), + session_key.clone(), Utc::now() + TimeDelta::minutes(5), - query_api_key_permissions(), - &tenant_id, + permissions, + &user.tenant, ); - guard.0 = Some(session_id); + req.extensions_mut().insert(session_key); + Some(session_id) } - - fut.await - }); - } + None => { + return Box::pin(async { Err(ErrorUnauthorized("Invalid API key")) }); + } + } + } else { + None + }; let key: Result = extract_session_key(&mut req); @@ -257,6 +216,18 @@ where let headers = req.headers().clone(); let fut = self.service.call(req); Box::pin(async move { + // Guard cleans up the ephemeral session created for an API-key + // request when this future finishes (success OR failure). + struct ApiKeySessionGuard(Option); + impl Drop for ApiKeySessionGuard { + fn drop(&mut self) { + if let Some(sid) = self.0 { + mut_sessions().remove_session(&SessionKey::SessionId(sid)); + } + } + } + let _api_key_guard = ApiKeySessionGuard(api_key_session_id); + let Ok(key) = key else { return Err(ErrorUnauthorized( "Your session has expired or is no longer valid. Please re-authenticate to access this resource.", @@ -327,25 +298,29 @@ fn extract_api_key(req: &ServiceRequest) -> Option { .map(String::from) } -/// Map an Action to the KeyType required for API key auth on that action. -/// Returns None if the action doesn't support API key auth. -fn api_key_type_for_action(action: &Action) -> Option { - use crate::apikeys::KeyType; - match action { - Action::Ingest => Some(KeyType::Ingestion), - Action::Query => Some(KeyType::Query), - _ => None, - } -} +/// Resolve an incoming API key value to its backing `UserType::ApiKey` user. +/// In multi-tenant deployments the `tenant_hint` is required and the lookup +/// is scoped to that tenant; in single-tenant deployments we look in the +/// default tenant. This prevents an API key from one tenant authenticating a +/// request that is claiming another. +fn find_api_key_user(api_key_value: &str, tenant_id: &Option) -> Option { + let tenant = if PARSEABLE.options.is_multi_tenant() { + tenant_id.as_deref()? + } else { + tenant_id.as_deref().unwrap_or(DEFAULT_TENANT) + }; -/// Build the set of permissions granted to a Query API key session. -/// Equivalent to the Reader privilege with no resource restriction -/// (global query access across all streams in the tenant). -fn query_api_key_permissions() -> Vec { - crate::rbac::role::RoleBuilder::from(&crate::rbac::role::model::DefaultPrivilege::Reader { - resource: None, + let users_guard = users(); + let tenant_users = users_guard.get(tenant)?; + tenant_users.values().find_map(|user| { + if let UserType::ApiKey(api_key) = &user.ty + && api_key.api_key == api_key_value + { + Some(user.clone()) + } else { + None + } }) - .build() } #[inline] diff --git a/src/handlers/http/modal/query/querier_rbac.rs b/src/handlers/http/modal/query/querier_rbac.rs index fee2fe986..576a500b8 100644 --- a/src/handlers/http/modal/query/querier_rbac.rs +++ b/src/handlers/http/modal/query/querier_rbac.rs @@ -140,6 +140,7 @@ pub async fn delete_user( let userid = match &user.ty { UserType::Native(basic) => basic.username.clone(), UserType::OAuth(oauth) => oauth.userid.clone(), + UserType::ApiKey(api_key) => api_key.userid.clone(), }; ug.remove_users_by_user_ids(HashSet::from_iter([userid]))?; groups_to_update.push(ug.clone()); diff --git a/src/handlers/http/rbac.rs b/src/handlers/http/rbac.rs index 0f8fd3840..552b52c53 100644 --- a/src/handlers/http/rbac.rs +++ b/src/handlers/http/rbac.rs @@ -45,7 +45,7 @@ use tokio::sync::Mutex; use super::modal::utils::rbac_utils::{get_metadata, put_metadata}; // async aware lock for updating storage metadata and user map atomically -pub(crate) static UPDATE_LOCK: Mutex<()> = Mutex::const_new(()); +pub static UPDATE_LOCK: Mutex<()> = Mutex::const_new(()); #[derive(serde::Serialize)] struct User { @@ -58,6 +58,7 @@ impl From<&user::User> for User { let method = match user.ty { user::UserType::Native(_) => "native".to_string(), user::UserType::OAuth(_) => "oauth".to_string(), + user::UserType::ApiKey(_) => "apikey".to_string(), }; User { @@ -84,7 +85,8 @@ pub async fn list_users_prism(req: HttpRequest) -> impl Responder { Some(users) => users .values() .filter_map(|u| { - if u.protected { + // Skip protected users and API-key-backed users. + if u.protected || u.is_api_key() { None } else { Some(to_prism_user(u)) @@ -150,7 +152,9 @@ pub async fn post_user( if Users.contains(&userid, &tenant_id) || metadata.users.iter().any(|user| match &user.ty { UserType::Native(basic) => basic.username == userid, - UserType::OAuth(_) => false, // OAuth users should be created differently + // OAuth users are created via the OIDC flow, and API key users + // are provisioned via the /apikeys endpoints. + UserType::OAuth(_) | UserType::ApiKey(_) => false, }) { return Err(RBACError::UserExists(userid)); diff --git a/src/metastore/metastore_traits.rs b/src/metastore/metastore_traits.rs index ace5eb077..79745ecf8 100644 --- a/src/metastore/metastore_traits.rs +++ b/src/metastore/metastore_traits.rs @@ -31,7 +31,6 @@ use crate::{ alert_structs::{AlertStateEntry, MTTRHistory}, target::Target, }, - apikeys::ApiKey, catalog::manifest::Manifest, handlers::http::modal::NodeType, metastore::MetastoreError, @@ -166,19 +165,6 @@ pub trait Metastore: std::fmt::Debug + Send + Sync { tenant_id: &Option, ) -> Result<(), MetastoreError>; - /// api keys - async fn get_api_keys(&self) -> Result>, MetastoreError>; - async fn put_api_key( - &self, - obj: &dyn MetastoreObject, - tenant_id: &Option, - ) -> Result<(), MetastoreError>; - async fn delete_api_key( - &self, - obj: &dyn MetastoreObject, - tenant_id: &Option, - ) -> Result<(), MetastoreError>; - /// dashboards async fn get_dashboards(&self) -> Result>, MetastoreError>; async fn put_dashboard( diff --git a/src/metastore/metastores/object_store_metastore.rs b/src/metastore/metastores/object_store_metastore.rs index 7403db126..904bcca24 100644 --- a/src/metastore/metastores/object_store_metastore.rs +++ b/src/metastore/metastores/object_store_metastore.rs @@ -36,7 +36,6 @@ use crate::{ alert_structs::{AlertStateEntry, MTTRHistory}, target::Target, }, - apikeys::ApiKey, catalog::{manifest::Manifest, partition_path}, handlers::http::{ modal::{Metadata, NodeMetadata, NodeType}, @@ -49,9 +48,9 @@ use crate::{ option::Mode, parseable::{DEFAULT_TENANT, PARSEABLE}, storage::{ - ALERTS_ROOT_DIRECTORY, APIKEYS_ROOT_DIRECTORY, ObjectStorage, ObjectStorageError, - PARSEABLE_ROOT_DIRECTORY, SETTINGS_ROOT_DIRECTORY, STREAM_METADATA_FILE_NAME, - STREAM_ROOT_DIRECTORY, TARGETS_ROOT_DIRECTORY, + ALERTS_ROOT_DIRECTORY, ObjectStorage, ObjectStorageError, PARSEABLE_ROOT_DIRECTORY, + SETTINGS_ROOT_DIRECTORY, STREAM_METADATA_FILE_NAME, STREAM_ROOT_DIRECTORY, + TARGETS_ROOT_DIRECTORY, object_storage::{ alert_json_path, alert_state_json_path, filter_path, manifest_path, mttr_json_path, parseable_json_path, schema_path, stream_json_path, to_bytes, @@ -1126,63 +1125,6 @@ impl Metastore for ObjectStoreMetastore { .await?) } - /// api keys - async fn get_api_keys(&self) -> Result>, MetastoreError> { - let base_paths = PARSEABLE.list_tenants().unwrap_or_else(|| vec!["".into()]); - let mut all_keys = HashMap::new(); - for mut tenant in base_paths { - let keys_path = RelativePathBuf::from_iter([ - &tenant, - SETTINGS_ROOT_DIRECTORY, - APIKEYS_ROOT_DIRECTORY, - ]); - let keys = self - .storage - .get_objects( - Some(&keys_path), - Box::new(|file_name| file_name.ends_with(".json")), - &Some(tenant.clone()), - ) - .await? - .iter() - .filter_map(|bytes| { - serde_json::from_slice(bytes) - .inspect_err(|err| warn!("Expected compatible api key json, error = {err}")) - .ok() - }) - .collect(); - if tenant.is_empty() { - tenant.clone_from(&DEFAULT_TENANT.to_string()); - } - all_keys.insert(tenant, keys); - } - Ok(all_keys) - } - - async fn put_api_key( - &self, - obj: &dyn MetastoreObject, - tenant_id: &Option, - ) -> Result<(), MetastoreError> { - let path = obj.get_object_path(); - Ok(self - .storage - .put_object(&RelativePathBuf::from(path), to_bytes(obj), tenant_id) - .await?) - } - - async fn delete_api_key( - &self, - obj: &dyn MetastoreObject, - tenant_id: &Option, - ) -> Result<(), MetastoreError> { - let path = obj.get_object_path(); - Ok(self - .storage - .delete_object(&RelativePathBuf::from(path), tenant_id) - .await?) - } - async fn get_all_schemas( &self, stream_name: &str, diff --git a/src/prism/home/mod.rs b/src/prism/home/mod.rs index f005f3d96..b45c625ea 100644 --- a/src/prism/home/mod.rs +++ b/src/prism/home/mod.rs @@ -163,9 +163,16 @@ pub async fn generate_home_response( // Generate checklist and count triggered alerts let data_ingested = datasets.iter().any(|d| d.ingestion); + // Count only real (non-protected, non-API-key) users. API-key users are + // managed separately via /apikeys and shouldn't satisfy the onboarding + // checklist "a user has been added" check. let user_count = users() .get(tenant_id.as_deref().unwrap_or(DEFAULT_TENANT)) - .map(|m| m.values().filter(|u| !u.protected).count()) + .map(|m| { + m.values() + .filter(|u| !u.protected && !u.is_api_key()) + .count() + }) .unwrap_or(0); let user_added = user_count > 1; // more than just the default admin user diff --git a/src/rbac/map.rs b/src/rbac/map.rs index 3b0016408..652e78ed4 100644 --- a/src/rbac/map.rs +++ b/src/rbac/map.rs @@ -389,11 +389,21 @@ impl Sessions { } } } else if resource_type.is_none() - && (action.eq(&Action::Ingest) - || action.eq(&Action::Query) - || action.eq(&Action::ListStream)) + && matches!( + action, + Action::Ingest + | Action::Query + | Action::ListStream + | Action::GetSchema + | Action::GetStats + | Action::GetRetention + | Action::PutRetention + | Action::GetLLM + | Action::QueryLLM + | Action::ListLLM + ) { - // flow for global-ingestion / global-query + // flow for global-ingestion / global-query / global-reader / global-writer let ok_resource = if let Some(context_resource_id) = context_resource { let is_internal = PARSEABLE diff --git a/src/rbac/mod.rs b/src/rbac/mod.rs index 20d702357..1d952bbab 100644 --- a/src/rbac/mod.rs +++ b/src/rbac/mod.rs @@ -109,7 +109,10 @@ impl Users { Some(users) => users .values() .filter_map(|user| { - if user.protected { + // Skip protected users and API-key-backed users — the + // latter are managed via the /apikeys endpoints, not + // surfaced in the regular users list. + if user.protected || user.is_api_key() { None } else { Some(user.into()) diff --git a/src/rbac/role.rs b/src/rbac/role.rs index 0a7562103..2145cb32b 100644 --- a/src/rbac/role.rs +++ b/src/rbac/role.rs @@ -195,7 +195,7 @@ pub mod model { Admin, Editor, Writer { - resource: ParseableResourceType, + resource: Option, }, Ingestor { resource: Option, @@ -299,7 +299,11 @@ pub mod model { DefaultPrivilege::Admin => admin_perm_builder(), DefaultPrivilege::Editor => editor_perm_builder(), DefaultPrivilege::Writer { resource } => { - writer_perm_builder().with_resource(resource.to_owned()) + if let Some(resource) = resource.as_ref() { + writer_perm_builder().with_resource(resource.to_owned()) + } else { + writer_perm_builder() + } } DefaultPrivilege::Reader { resource } => { if let Some(resource) = resource.as_ref() { diff --git a/src/rbac/user.rs b/src/rbac/user.rs index f661a64cb..e9cecc4c2 100644 --- a/src/rbac/user.rs +++ b/src/rbac/user.rs @@ -23,11 +23,13 @@ use argon2::{ password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, }; +use chrono::{DateTime, Utc}; use openid::Bearer; use rand::{ RngCore, distributions::{Alphanumeric, DistString}, }; +use ulid::Ulid; use crate::{ handlers::http::rbac::{InvalidUserGroupError, RBACError}, @@ -42,8 +44,13 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(untagged)] pub enum UserType { - Native(Basic), + // Order matters for `#[serde(untagged)]`: the most specific variant must + // come first so it wins deserialization when its distinctive fields are + // present. `ApiKey` carries `api_key`/`key_id` which neither `Native` nor + // `OAuth` have. + ApiKey(Box), OAuth(Box), + Native(Basic), } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -76,6 +83,32 @@ impl User { ) } + pub fn new_api_key( + key_id: Ulid, + api_key: String, + key_name: String, + roles: HashSet, + created_by: String, + tenant: Option, + ) -> Self { + let now = Utc::now(); + Self { + ty: UserType::ApiKey(Box::new(ApiKeyUser { + userid: key_id.to_string(), + key_id, + api_key, + key_name, + created_by, + created_at: now, + modified_at: now, + })), + roles, + user_groups: HashSet::new(), + tenant, + protected: false, + } + } + pub fn new_oauth( userid: String, roles: HashSet, @@ -101,6 +134,7 @@ impl User { match self.ty { UserType::Native(Basic { ref username, .. }) => username, UserType::OAuth(ref oauth) => &oauth.userid, + UserType::ApiKey(ref api_key) => &api_key.userid, } } @@ -116,6 +150,7 @@ impl User { .unwrap_or_else(|| oauth.userid.clone()) }) } + UserType::ApiKey(api_key) => api_key.key_name.clone(), } } @@ -123,6 +158,18 @@ impl User { matches!(self.ty, UserType::OAuth(_)) } + pub fn is_api_key(&self) -> bool { + matches!(self.ty, UserType::ApiKey(_)) + } + + /// Return the underlying API key data if this user represents an API key. + pub fn as_api_key(&self) -> Option<&ApiKeyUser> { + match &self.ty { + UserType::ApiKey(api_key) => Some(api_key), + _ => None, + } + } + pub fn is_super_admin(&self) -> bool { self.roles.contains("super-admin") } @@ -201,6 +248,24 @@ pub fn get_super_admin_user() -> User { } } +/// A user backed by an API key. `userid` is the stable identifier used +/// everywhere a userid is expected (sessions, groups, audit logs); it equals +/// `key_id.to_string()` (same value as `keyId`, kept as `String` so +/// `User::userid()` can return `&str` alongside the Native/OAuth variants). +/// Permissions for this user are derived from the `roles` set on the parent +/// `User`, exactly like native and OAuth users. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiKeyUser { + pub userid: String, + pub key_id: Ulid, + pub api_key: String, + pub key_name: String, + pub created_by: String, + pub created_at: DateTime, + pub modified_at: DateTime, +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct OAuth { pub userid: String, @@ -332,6 +397,12 @@ impl GroupUser { tenant_id: user.tenant.clone(), } } + UserType::ApiKey(api_key) => GroupUser { + userid: api_key.userid.clone(), + username: api_key.key_name.clone(), + method: "apikey".to_string(), + tenant_id: user.tenant.clone(), + }, } } } @@ -578,3 +649,69 @@ impl UserGroup { self.remove_users(users_to_remove) } } + +#[cfg(test)] +mod apikey_serde_tests { + use super::*; + + #[test] + fn user_roundtrip_covers_api_key_variant() { + let key_id = Ulid::new(); + let roles: HashSet = ["admin".to_string()].into_iter().collect(); + let user = User::new_api_key( + key_id, + "secret-token".into(), + "my-key".into(), + roles.clone(), + "admin-user".into(), + Some("tenant-a".into()), + ); + + // userid is derived from key_id with no prefix. + assert_eq!(user.userid(), key_id.to_string()); + + let json = serde_json::to_string(&user).expect("serialize User(ApiKey)"); + assert!( + json.contains("\"apiKey\":\"secret-token\""), + "json = {json}" + ); + assert!(json.contains("\"roles\""), "json = {json}"); + assert!(json.contains("\"userid\""), "json = {json}"); + + let back: User = serde_json::from_str(&json).expect("deserialize User"); + assert!(back.is_api_key()); + assert_eq!(back.userid(), user.userid()); + assert_eq!(back.roles, roles); + let ak = back.as_api_key().expect("ApiKey variant"); + assert_eq!(ak.key_id, key_id); + assert_eq!(ak.userid, key_id.to_string()); + assert_eq!(ak.api_key, "secret-token"); + } + + #[test] + fn native_user_still_roundtrips_with_api_key_variant_present() { + let (user, _pw) = User::new_basic("alice".into(), Some("tenant-a".into()), false); + let json = serde_json::to_string(&user).unwrap(); + let back: User = serde_json::from_str(&json).unwrap(); + assert!(matches!(back.ty, UserType::Native(_))); + assert_eq!(back.userid(), "alice"); + } + + #[test] + fn dump_api_key_json() { + let key_id = Ulid::from_string("01KPWAKVAQVXJGTKRKZCSVVCDF").unwrap(); + let roles: HashSet = ["admin".to_string()].into_iter().collect(); + let user = User::new_api_key( + key_id, + "550e8400-e29b-41d4-a716-446655440000".into(), + "diag-test-key".into(), + roles, + "admin".into(), + None, + ); + let pretty = serde_json::to_string_pretty(&user).unwrap(); + println!( + "\n=== user json (persisted shape) ===\n{pretty}\n====================================\n" + ); + } +} diff --git a/src/rbac/utils.rs b/src/rbac/utils.rs index 62bb0469c..9e6000f5c 100644 --- a/src/rbac/utils.rs +++ b/src/rbac/utils.rs @@ -45,6 +45,13 @@ pub fn to_prism_user(user: &User) -> UsersPrism { oauth.user_info.picture.clone(), ) } + UserType::ApiKey(api_key) => ( + user.userid(), + api_key.key_name.as_str(), + "apikey", + None, + None, + ), }; let direct_roles: HashMap = Users .get_role(id, &user.tenant) diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 66b6e2625..1694e5f5b 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -67,7 +67,6 @@ pub const SCHEMA_FILE_NAME: &str = ".schema"; pub const ALERTS_ROOT_DIRECTORY: &str = ".alerts"; pub const SETTINGS_ROOT_DIRECTORY: &str = ".settings"; pub const TARGETS_ROOT_DIRECTORY: &str = ".targets"; -pub const APIKEYS_ROOT_DIRECTORY: &str = "apikeys"; pub const MANIFEST_FILE: &str = "manifest.json"; // max concurrent request allowed for datafusion object store diff --git a/src/storage/object_storage.rs b/src/storage/object_storage.rs index fb6651c76..67aeac609 100644 --- a/src/storage/object_storage.rs +++ b/src/storage/object_storage.rs @@ -58,7 +58,6 @@ use crate::option::Mode; use crate::parseable::DEFAULT_TENANT; use crate::parseable::{LogStream, PARSEABLE, Stream}; use crate::stats::FullStats; -use crate::storage::APIKEYS_ROOT_DIRECTORY; use crate::storage::SETTINGS_ROOT_DIRECTORY; use crate::storage::TARGETS_ROOT_DIRECTORY; use crate::storage::field_stats::DATASET_STATS_STREAM_NAME; @@ -1348,19 +1347,6 @@ pub fn mttr_json_path(tenant_id: &Option) -> RelativePathBuf { } } -/// Constructs the path for storing API key JSON files -/// Format: "{tenant}/.settings/apikeys/{key_id}.json" -#[inline(always)] -pub fn apikey_json_path(key_id: &Ulid, tenant_id: &Option) -> RelativePathBuf { - let root = tenant_id.as_deref().unwrap_or(""); - RelativePathBuf::from_iter([ - root, - SETTINGS_ROOT_DIRECTORY, - APIKEYS_ROOT_DIRECTORY, - &format!("{key_id}.json"), - ]) -} - #[inline(always)] pub fn manifest_path(prefix: &str) -> RelativePathBuf { let hostname = hostname::get()