From ee7970f6032327d366338582e82e67335a2c74a0 Mon Sep 17 00:00:00 2001 From: many Date: Mon, 6 Dec 2021 15:45:41 +0100 Subject: [PATCH] feat(auth): Extend API keys - Add API keys in snapshots - Add API keys in dumps - Rename action indexes.add to indexes.create - fix QA #1979 fix #1979 fix #1995 fix #2001 fix #2003 related to #1890 --- Cargo.lock | 1 + meilisearch-auth/src/action.rs | 10 +- meilisearch-auth/src/dump.rs | 40 ++++ meilisearch-auth/src/error.rs | 7 +- meilisearch-auth/src/key.rs | 40 +++- meilisearch-auth/src/lib.rs | 3 +- meilisearch-auth/src/store.rs | 2 + .../src/extractors/authentication/mod.rs | 95 ++++---- meilisearch-http/src/lib.rs | 40 +--- meilisearch-http/src/routes/api_key.rs | 19 +- meilisearch-http/src/routes/indexes/mod.rs | 2 +- meilisearch-http/src/routes/tasks.rs | 21 +- meilisearch-http/tests/auth/api_keys.rs | 213 ++++++++++++------ meilisearch-http/tests/auth/authorization.rs | 106 ++++++++- meilisearch-lib/Cargo.toml | 1 + .../src/index_controller/dump_actor/error.rs | 2 + .../index_controller/dump_actor/loaders/v4.rs | 2 + .../src/index_controller/dump_actor/mod.rs | 3 + meilisearch-lib/src/snapshot.rs | 15 ++ 19 files changed, 418 insertions(+), 204 deletions(-) create mode 100644 meilisearch-auth/src/dump.rs diff --git a/Cargo.lock b/Cargo.lock index e3635645d..94097618e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1749,6 +1749,7 @@ dependencies = [ "itertools", "lazy_static", "log", + "meilisearch-auth", "meilisearch-error", "milli", "mime", diff --git a/meilisearch-auth/src/action.rs b/meilisearch-auth/src/action.rs index 59f108fc3..7ffe9b908 100644 --- a/meilisearch-auth/src/action.rs +++ b/meilisearch-auth/src/action.rs @@ -14,8 +14,8 @@ pub enum Action { DocumentsGet = actions::DOCUMENTS_GET, #[serde(rename = "documents.delete")] DocumentsDelete = actions::DOCUMENTS_DELETE, - #[serde(rename = "indexes.add")] - IndexesAdd = actions::INDEXES_ADD, + #[serde(rename = "indexes.create")] + IndexesAdd = actions::INDEXES_CREATE, #[serde(rename = "indexes.get")] IndexesGet = actions::INDEXES_GET, #[serde(rename = "indexes.update")] @@ -47,7 +47,7 @@ impl Action { DOCUMENTS_ADD => Some(Self::DocumentsAdd), DOCUMENTS_GET => Some(Self::DocumentsGet), DOCUMENTS_DELETE => Some(Self::DocumentsDelete), - INDEXES_ADD => Some(Self::IndexesAdd), + INDEXES_CREATE => Some(Self::IndexesAdd), INDEXES_GET => Some(Self::IndexesGet), INDEXES_UPDATE => Some(Self::IndexesUpdate), INDEXES_DELETE => Some(Self::IndexesDelete), @@ -70,7 +70,7 @@ impl Action { Self::DocumentsAdd => DOCUMENTS_ADD, Self::DocumentsGet => DOCUMENTS_GET, Self::DocumentsDelete => DOCUMENTS_DELETE, - Self::IndexesAdd => INDEXES_ADD, + Self::IndexesAdd => INDEXES_CREATE, Self::IndexesGet => INDEXES_GET, Self::IndexesUpdate => INDEXES_UPDATE, Self::IndexesDelete => INDEXES_DELETE, @@ -90,7 +90,7 @@ pub mod actions { pub const DOCUMENTS_ADD: u8 = 2; pub const DOCUMENTS_GET: u8 = 3; pub const DOCUMENTS_DELETE: u8 = 4; - pub const INDEXES_ADD: u8 = 5; + pub const INDEXES_CREATE: u8 = 5; pub const INDEXES_GET: u8 = 6; pub const INDEXES_UPDATE: u8 = 7; pub const INDEXES_DELETE: u8 = 8; diff --git a/meilisearch-auth/src/dump.rs b/meilisearch-auth/src/dump.rs new file mode 100644 index 000000000..f93221ed6 --- /dev/null +++ b/meilisearch-auth/src/dump.rs @@ -0,0 +1,40 @@ +use std::fs::File; +use std::io::BufRead; +use std::io::BufReader; +use std::io::Write; +use std::path::Path; + +use crate::{AuthController, HeedAuthStore, Result}; + +const KEYS_PATH: &str = "keys"; + +impl AuthController { + pub fn dump(src: impl AsRef, dst: impl AsRef) -> Result<()> { + let store = HeedAuthStore::new(&src)?; + + let keys_file_path = dst.as_ref().join(KEYS_PATH); + + let keys = store.list_api_keys()?; + let mut keys_file = File::create(&keys_file_path)?; + for key in keys { + serde_json::to_writer(&mut keys_file, &key)?; + keys_file.write_all(b"\n")?; + } + + Ok(()) + } + + pub fn load_dump(src: impl AsRef, dst: impl AsRef) -> Result<()> { + let store = HeedAuthStore::new(&dst)?; + + let keys_file_path = src.as_ref().join(KEYS_PATH); + + let mut reader = BufReader::new(File::open(&keys_file_path)?).lines(); + while let Some(key) = reader.next().transpose()? { + let key = serde_json::from_str(&key)?; + store.put_api_key(key)?; + } + + Ok(()) + } +} diff --git a/meilisearch-auth/src/error.rs b/meilisearch-auth/src/error.rs index 24ea88ff6..8fa6b8430 100644 --- a/meilisearch-auth/src/error.rs +++ b/meilisearch-auth/src/error.rs @@ -24,7 +24,12 @@ pub enum AuthControllerError { Internal(Box), } -internal_error!(AuthControllerError: heed::Error, std::io::Error); +internal_error!( + AuthControllerError: heed::Error, + std::io::Error, + serde_json::Error, + std::str::Utf8Error +); impl ErrorCode for AuthControllerError { fn error_code(&self) -> Code { diff --git a/meilisearch-auth/src/key.rs b/meilisearch-auth/src/key.rs index 358630a40..b531d5e3e 100644 --- a/meilisearch-auth/src/key.rs +++ b/meilisearch-auth/src/key.rs @@ -1,7 +1,7 @@ use crate::action::Action; use crate::error::{AuthControllerError, Result}; use crate::store::{KeyId, KEY_ID_LENGTH}; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, NaiveDateTime, Utc}; use rand::Rng; use serde::{Deserialize, Serialize}; use serde_json::{from_value, Value}; @@ -48,11 +48,8 @@ impl Key { let expires_at = value .get("expiresAt") - .map(|exp| { - from_value(exp.clone()) - .map_err(|_| AuthControllerError::InvalidApiKeyExpiresAt(exp.clone())) - }) - .transpose()?; + .map(parse_expiration_date) + .ok_or(AuthControllerError::MissingParameter("expiresAt"))??; let created_at = Utc::now(); let updated_at = Utc::now(); @@ -88,9 +85,7 @@ impl Key { } if let Some(exp) = value.get("expiresAt") { - let exp = from_value(exp.clone()) - .map_err(|_| AuthControllerError::InvalidApiKeyExpiresAt(exp.clone())); - self.expires_at = exp?; + self.expires_at = parse_expiration_date(exp)?; } self.updated_at = Utc::now(); @@ -137,3 +132,30 @@ fn generate_id() -> [u8; KEY_ID_LENGTH] { bytes } + +fn parse_expiration_date(value: &Value) -> Result>> { + match value { + Value::String(string) => DateTime::parse_from_rfc3339(string) + .map(|d| d.into()) + .or_else(|_| { + NaiveDateTime::parse_from_str(string, "%Y-%m-%dT%H:%M:%S") + .map(|naive| DateTime::from_utc(naive, Utc)) + }) + .or_else(|_| { + NaiveDateTime::parse_from_str(string, "%Y-%m-%d") + .map(|naive| DateTime::from_utc(naive, Utc)) + }) + .map_err(|_| AuthControllerError::InvalidApiKeyExpiresAt(value.clone())) + // check if the key is already expired. + .and_then(|d| { + if d > Utc::now() { + Ok(d) + } else { + Err(AuthControllerError::InvalidApiKeyExpiresAt(value.clone())) + } + }) + .map(Option::Some), + Value::Null => Ok(None), + _otherwise => Err(AuthControllerError::InvalidApiKeyExpiresAt(value.clone())), + } +} diff --git a/meilisearch-auth/src/lib.rs b/meilisearch-auth/src/lib.rs index de8a053c9..0f476ac16 100644 --- a/meilisearch-auth/src/lib.rs +++ b/meilisearch-auth/src/lib.rs @@ -1,4 +1,5 @@ mod action; +mod dump; pub mod error; mod key; mod store; @@ -104,7 +105,7 @@ impl AuthController { None => self.store.prefix_first_expiration_date(token, action)?, }) { - let id = from_utf8(&id).map_err(|e| AuthControllerError::Internal(Box::new(e)))?; + let id = from_utf8(&id)?; if exp.map_or(true, |exp| Utc::now() < exp) && generate_key(master_key.as_bytes(), id).as_bytes() == token { diff --git a/meilisearch-auth/src/store.rs b/meilisearch-auth/src/store.rs index 3b309e6b8..e1501af30 100644 --- a/meilisearch-auth/src/store.rs +++ b/meilisearch-auth/src/store.rs @@ -1,5 +1,6 @@ use enum_iterator::IntoEnumIterator; use std::borrow::Cow; +use std::cmp::Reverse; use std::convert::TryFrom; use std::convert::TryInto; use std::fs::create_dir_all; @@ -121,6 +122,7 @@ impl HeedAuthStore { let (_, content) = result?; list.push(content); } + list.sort_unstable_by_key(|k| Reverse(k.created_at)); Ok(list) } diff --git a/meilisearch-http/src/extractors/authentication/mod.rs b/meilisearch-http/src/extractors/authentication/mod.rs index af747ac3a..2c960578a 100644 --- a/meilisearch-http/src/extractors/authentication/mod.rs +++ b/meilisearch-http/src/extractors/authentication/mod.rs @@ -32,7 +32,7 @@ impl Deref for GuardedData { } impl FromRequest for GuardedData { - type Config = AuthConfig; + type Config = (); type Error = ResponseError; @@ -42,49 +42,44 @@ impl FromRequest for GuardedData req: &actix_web::HttpRequest, _payload: &mut actix_web::dev::Payload, ) -> Self::Future { - match req.app_data::() { - Some(config) => match config { - AuthConfig::NoAuth => match req.app_data::().cloned() { - Some(data) => ok(Self { - data, - filters: AuthFilter::default(), - _marker: PhantomData, - }), - None => err(AuthenticationError::IrretrievableState.into()), + match req.app_data::().cloned() { + Some(auth) => match req + .headers() + .get("Authorization") + .map(|type_token| type_token.to_str().unwrap_or_default().splitn(2, ' ')) + { + Some(mut type_token) => match type_token.next() { + Some("Bearer") => { + // TODO: find a less hardcoded way? + let index = req.match_info().get("index_uid"); + let token = type_token.next().unwrap_or("unknown"); + match P::authenticate(auth, token, index) { + Some(filters) => match req.app_data::().cloned() { + Some(data) => ok(Self { + data, + filters, + _marker: PhantomData, + }), + None => err(AuthenticationError::IrretrievableState.into()), + }, + None => { + let token = token.to_string(); + err(AuthenticationError::InvalidToken(token).into()) + } + } + } + _otherwise => err(AuthenticationError::MissingAuthorizationHeader.into()), }, - AuthConfig::Auth => match req.app_data::().cloned() { - Some(auth) => match req - .headers() - .get("Authorization") - .map(|type_token| type_token.to_str().unwrap_or_default().splitn(2, ' ')) - { - Some(mut type_token) => match type_token.next() { - Some("Bearer") => { - // TODO: find a less hardcoded way? - let index = req.match_info().get("index_uid"); - let token = type_token.next().unwrap_or("unknown"); - match P::authenticate(auth, token, index) { - Some(filters) => match req.app_data::().cloned() { - Some(data) => ok(Self { - data, - filters, - _marker: PhantomData, - }), - None => err(AuthenticationError::IrretrievableState.into()), - }, - None => { - let token = token.to_string(); - err(AuthenticationError::InvalidToken(token).into()) - } - } - } - _otherwise => { - err(AuthenticationError::MissingAuthorizationHeader.into()) - } - }, - None => err(AuthenticationError::MissingAuthorizationHeader.into()), + None => match P::authenticate(auth, "", None) { + Some(filters) => match req.app_data::().cloned() { + Some(data) => ok(Self { + data, + filters, + _marker: PhantomData, + }), + None => err(AuthenticationError::IrretrievableState.into()), }, - None => err(AuthenticationError::IrretrievableState.into()), + None => err(AuthenticationError::MissingAuthorizationHeader.into()), }, }, None => err(AuthenticationError::IrretrievableState.into()), @@ -129,10 +124,8 @@ pub mod policies { index: Option<&str>, ) -> Option { // authenticate if token is the master key. - if let Some(master_key) = auth.get_master_key() { - if master_key == token { - return Some(AuthFilter::default()); - } + if auth.get_master_key().map_or(true, |mk| mk == token) { + return Some(AuthFilter::default()); } // authenticate if token is allowed. @@ -147,13 +140,3 @@ pub mod policies { } } } -pub enum AuthConfig { - NoAuth, - Auth, -} - -impl Default for AuthConfig { - fn default() -> Self { - Self::NoAuth - } -} diff --git a/meilisearch-http/src/lib.rs b/meilisearch-http/src/lib.rs index c90fc5185..01a74887b 100644 --- a/meilisearch-http/src/lib.rs +++ b/meilisearch-http/src/lib.rs @@ -13,7 +13,6 @@ use std::sync::Arc; use std::time::Duration; use crate::error::MeilisearchHttpError; -use crate::extractors::authentication::AuthConfig; use actix_web::error::JsonPayloadError; use analytics::Analytics; use error::PayloadError; @@ -25,31 +24,6 @@ use actix_web::{web, HttpRequest}; use extractors::payload::PayloadConfig; use meilisearch_auth::AuthController; use meilisearch_lib::MeiliSearch; -use sha2::Digest; - -#[derive(Clone)] -pub struct ApiKeys { - pub public: Option, - pub private: Option, - pub master: Option, -} - -impl ApiKeys { - pub fn generate_missing_api_keys(&mut self) { - if let Some(master_key) = &self.master { - if self.private.is_none() { - let key = format!("{}-private", master_key); - let sha = sha2::Sha256::digest(key.as_bytes()); - self.private = Some(format!("{:x}", sha)); - } - if self.public.is_none() { - let key = format!("{}-public", master_key); - let sha = sha2::Sha256::digest(key.as_bytes()); - self.public = Some(format!("{:x}", sha)); - } - } - } -} pub fn setup_meilisearch(opt: &Opt) -> anyhow::Result { let mut meilisearch = MeiliSearch::builder(); @@ -113,16 +87,6 @@ pub fn configure_data( ); } -pub fn configure_auth(config: &mut web::ServiceConfig, opts: &Opt) { - let auth_config = if opts.master_key.is_some() { - AuthConfig::Auth - } else { - AuthConfig::NoAuth - }; - - config.app_data(auth_config); -} - #[cfg(feature = "mini-dashboard")] pub fn dashboard(config: &mut web::ServiceConfig, enable_frontend: bool) { use actix_web::HttpResponse; @@ -170,17 +134,15 @@ macro_rules! create_app { use meilisearch_error::ResponseError; use meilisearch_http::error::MeilisearchHttpError; use meilisearch_http::routes; - use meilisearch_http::{configure_auth, configure_data, dashboard}; + use meilisearch_http::{configure_data, dashboard}; App::new() .configure(|s| configure_data(s, $data.clone(), $auth.clone(), &$opt, $analytics)) - .configure(|s| configure_auth(s, &$opt)) .configure(routes::configure) .configure(|s| dashboard(s, $enable_frontend)) .wrap( Cors::default() .send_wildcard() - .allowed_headers(vec!["content-type", "x-meili-api-key"]) .allow_any_origin() .allow_any_method() .max_age(86_400), // 24h diff --git a/meilisearch-http/src/routes/api_key.rs b/meilisearch-http/src/routes/api_key.rs index e77685493..a64a17207 100644 --- a/meilisearch-http/src/routes/api_key.rs +++ b/meilisearch-http/src/routes/api_key.rs @@ -1,7 +1,7 @@ use std::str; use actix_web::{web, HttpRequest, HttpResponse}; -use chrono::{DateTime, Utc}; +use chrono::SecondsFormat; use log::debug; use meilisearch_auth::{generate_key, Action, AuthController, Key}; use serde::{Deserialize, Serialize}; @@ -84,7 +84,7 @@ pub async fn delete_api_key( // keep 8 first characters that are the ID of the API key. auth_controller.delete_key(&path.api_key).await?; - Ok(HttpResponse::NoContent().json(())) + Ok(HttpResponse::NoContent().finish()) } #[derive(Deserialize)] @@ -95,14 +95,13 @@ pub struct AuthParam { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct KeyView { - #[serde(skip_serializing_if = "Option::is_none")] description: Option, key: String, actions: Vec, indexes: Vec, - expires_at: Option>, - created_at: DateTime, - updated_at: DateTime, + expires_at: Option, + created_at: String, + updated_at: String, } impl KeyView { @@ -118,9 +117,11 @@ impl KeyView { key: generated_key, actions: key.actions, indexes: key.indexes, - expires_at: key.expires_at, - created_at: key.created_at, - updated_at: key.updated_at, + expires_at: key + .expires_at + .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true)), + created_at: key.created_at.to_rfc3339_opts(SecondsFormat::Secs, true), + updated_at: key.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true), } } } diff --git a/meilisearch-http/src/routes/indexes/mod.rs b/meilisearch-http/src/routes/indexes/mod.rs index 485e03bc4..c24a5e662 100644 --- a/meilisearch-http/src/routes/indexes/mod.rs +++ b/meilisearch-http/src/routes/indexes/mod.rs @@ -62,7 +62,7 @@ pub struct IndexCreateRequest { } pub async fn create_index( - meilisearch: GuardedData, MeiliSearch>, + meilisearch: GuardedData, MeiliSearch>, body: web::Json, req: HttpRequest, analytics: web::Data, diff --git a/meilisearch-http/src/routes/tasks.rs b/meilisearch-http/src/routes/tasks.rs index 158d8570c..09e0a21b6 100644 --- a/meilisearch-http/src/routes/tasks.rs +++ b/meilisearch-http/src/routes/tasks.rs @@ -1,6 +1,7 @@ use actix_web::{web, HttpRequest, HttpResponse}; use meilisearch_error::ResponseError; use meilisearch_lib::tasks::task::TaskId; +use meilisearch_lib::tasks::TaskFilter; use meilisearch_lib::MeiliSearch; use serde_json::json; @@ -24,8 +25,16 @@ async fn get_tasks( Some(&req), ); + let filters = meilisearch.filters().indexes.as_ref().map(|indexes| { + let mut filters = TaskFilter::default(); + for index in indexes { + filters.filter_index(index.to_string()); + } + filters + }); + let tasks: TaskListView = meilisearch - .list_tasks(None, None, None) + .list_tasks(filters, None, None) .await? .into_iter() .map(TaskView::from) @@ -47,8 +56,16 @@ async fn get_task( Some(&req), ); + let filters = meilisearch.filters().indexes.as_ref().map(|indexes| { + let mut filters = TaskFilter::default(); + for index in indexes { + filters.filter_index(index.to_string()); + } + filters + }); + let task: TaskView = meilisearch - .get_task(task_id.into_inner(), None) + .get_task(task_id.into_inner(), filters) .await? .into(); diff --git a/meilisearch-http/tests/auth/api_keys.rs b/meilisearch-http/tests/auth/api_keys.rs index 4549b3900..e3cb775d5 100644 --- a/meilisearch-http/tests/auth/api_keys.rs +++ b/meilisearch-http/tests/auth/api_keys.rs @@ -1,6 +1,7 @@ use crate::common::Server; use assert_json_diff::assert_json_include; use serde_json::json; +use std::{thread, time}; #[actix_rt::test] async fn add_valid_api_key() { @@ -15,7 +16,7 @@ async fn add_valid_api_key() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -43,7 +44,7 @@ async fn add_valid_api_key() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -71,7 +72,7 @@ async fn add_valid_api_key_no_description() { "actions": [ "documents.add" ], - "expiresAt": "2050-11-13T00:00:00Z" + "expiresAt": "2050-11-13T00:00:00" }); let (response, code) = server.add_api_key(content).await; @@ -153,9 +154,7 @@ async fn error_add_api_key_missing_parameter() { // missing indexes let content = json!({ "description": "Indexing API key", - "actions": [ - "documents.add" - ], + "actions": ["documents.add"], "expiresAt": "2050-11-13T00:00:00Z" }); let (response, code) = server.add_api_key(content).await; @@ -187,6 +186,24 @@ async fn error_add_api_key_missing_parameter() { assert_eq!(response, expected_response); assert_eq!(code, 400); + + // missing expiration date + let content = json!({ + "description": "Indexing API key", + "indexes": ["products"], + "actions": ["documents.add"], + }); + let (response, code) = server.add_api_key(content).await; + + let expected_response = json!({ + "message": "`expiresAt` field is mandatory.", + "code": "missing_parameter", + "type": "invalid_request", + "link":"https://docs.meilisearch.com/errors#missing_parameter" + }); + + assert_eq!(response, expected_response); + assert_eq!(code, 400); } #[actix_rt::test] @@ -311,6 +328,32 @@ async fn error_add_api_key_invalid_parameters_expires_at() { assert_eq!(code, 400); } +#[actix_rt::test] +async fn error_add_api_key_invalid_parameters_expires_at_in_the_past() { + let mut server = Server::new_auth().await; + server.use_api_key("MASTER_KEY"); + + let content = json!({ + "description": "Indexing API key", + "indexes": ["products"], + "actions": [ + "documents.add" + ], + "expiresAt": "2010-11-13T00:00:00Z" + }); + let (response, code) = server.add_api_key(content).await; + + let expected_response = json!({ + "message": r#"expiresAt field value `"2010-11-13T00:00:00Z"` is invalid. It should be in ISO-8601 format to represents a date or datetime in the future or specified as a null value. e.g. 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS'."#, + "code": "invalid_api_key_expires_at", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_api_key_expires_at" + }); + + assert_eq!(response, expected_response); + assert_eq!(code, 400); +} + #[actix_rt::test] async fn get_api_key() { let mut server = Server::new_auth().await; @@ -324,7 +367,7 @@ async fn get_api_key() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -359,7 +402,7 @@ async fn get_api_key() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -449,7 +492,7 @@ async fn list_api_keys() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -468,19 +511,9 @@ async fn list_api_keys() { assert_eq!(code, 201); let (response, code) = server.list_api_keys().await; - assert!(response.is_array()); - let response = &response.as_array().unwrap(); - let created_key = response - .iter() - .find(|x| x["description"] == "Indexing API key") - .unwrap(); - assert!(created_key["key"].is_string()); - assert!(created_key["expiresAt"].is_string()); - assert!(created_key["createdAt"].is_string()); - assert!(created_key["updatedAt"].is_string()); - - let expected_response = json!({ + let expected_response = json!([ + { "description": "Indexing API key", "indexes": ["products"], "actions": [ @@ -488,7 +521,7 @@ async fn list_api_keys() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -500,49 +533,21 @@ async fn list_api_keys() { "dumps.get" ], "expiresAt": "2050-11-13T00:00:00Z" - }); - - assert_json_include!(actual: created_key, expected: expected_response); - assert_eq!(code, 200); - - // check if default admin key is present. - let admin_key = response - .iter() - .find(|x| x["description"] == "Default Admin API Key (Use it for all other operations. Caution! Do not use it on a public frontend)") - .unwrap(); - assert!(created_key["key"].is_string()); - assert!(created_key["expiresAt"].is_string()); - assert!(created_key["createdAt"].is_string()); - assert!(created_key["updatedAt"].is_string()); - - let expected_response = json!({ - "description": "Default Admin API Key (Use it for all other operations. Caution! Do not use it on a public frontend)", - "indexes": ["*"], - "actions": ["*"], - "expiresAt": serde_json::Value::Null, - }); - - assert_json_include!(actual: admin_key, expected: expected_response); - assert_eq!(code, 200); - - // check if default search key is present. - let admin_key = response - .iter() - .find(|x| x["description"] == "Default Search API Key (Use it to search from the frontend)") - .unwrap(); - assert!(created_key["key"].is_string()); - assert!(created_key["expiresAt"].is_string()); - assert!(created_key["createdAt"].is_string()); - assert!(created_key["updatedAt"].is_string()); - - let expected_response = json!({ + }, + { "description": "Default Search API Key (Use it to search from the frontend)", "indexes": ["*"], "actions": ["search"], "expiresAt": serde_json::Value::Null, - }); + }, + { + "description": "Default Admin API Key (Use it for all other operations. Caution! Do not use it on a public frontend)", + "indexes": ["*"], + "actions": ["*"], + "expiresAt": serde_json::Value::Null, + }]); - assert_json_include!(actual: admin_key, expected: expected_response); + assert_json_include!(actual: response, expected: expected_response); assert_eq!(code, 200); } @@ -594,7 +599,7 @@ async fn delete_api_key() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -694,7 +699,7 @@ async fn patch_api_key_description() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -719,6 +724,7 @@ async fn patch_api_key_description() { // Add a description let content = json!({ "description": "Indexing API key" }); + thread::sleep(time::Duration::new(1, 0)); let (response, code) = server.patch_api_key(&key, content).await; assert!(response["key"].is_string()); assert!(response["expiresAt"].is_string()); @@ -734,7 +740,7 @@ async fn patch_api_key_description() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -764,7 +770,7 @@ async fn patch_api_key_description() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -793,7 +799,7 @@ async fn patch_api_key_description() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -821,7 +827,7 @@ async fn patch_api_key_indexes() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -845,6 +851,7 @@ async fn patch_api_key_indexes() { let content = json!({ "indexes": ["products", "prices"] }); + thread::sleep(time::Duration::new(1, 0)); let (response, code) = server.patch_api_key(&key, content).await; assert!(response["key"].is_string()); assert!(response["expiresAt"].is_string()); @@ -860,7 +867,7 @@ async fn patch_api_key_indexes() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -888,7 +895,7 @@ async fn patch_api_key_actions() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -920,6 +927,7 @@ async fn patch_api_key_actions() { ], }); + thread::sleep(time::Duration::new(1, 0)); let (response, code) = server.patch_api_key(&key, content).await; assert!(response["key"].is_string()); assert!(response["expiresAt"].is_string()); @@ -957,7 +965,7 @@ async fn patch_api_key_expiration_date() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -965,7 +973,7 @@ async fn patch_api_key_expiration_date() { "dumps.create", "dumps.get" ], - "expiresAt": "205-11-13T00:00:00Z" + "expiresAt": "2050-11-13T00:00:00Z" }); let (response, code) = server.add_api_key(content).await; @@ -981,6 +989,7 @@ async fn patch_api_key_expiration_date() { let content = json!({ "expiresAt": "2055-11-13T00:00:00Z" }); + thread::sleep(time::Duration::new(1, 0)); let (response, code) = server.patch_api_key(&key, content).await; assert!(response["key"].is_string()); assert!(response["expiresAt"].is_string()); @@ -996,7 +1005,7 @@ async fn patch_api_key_expiration_date() { "documents.add", "documents.get", "documents.delete", - "indexes.add", + "indexes.create", "indexes.get", "indexes.update", "indexes.delete", @@ -1166,3 +1175,65 @@ async fn error_patch_api_key_indexes_invalid_parameters() { assert_eq!(response, expected_response); assert_eq!(code, 400); } + +#[actix_rt::test] +async fn error_access_api_key_routes_no_master_key_set() { + let mut server = Server::new().await; + + let expected_response = json!({ + "message": "The Authorization header is missing. It must use the bearer authorization method.", + "code": "missing_authorization_header", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#missing_authorization_header" + }); + let expected_code = 401; + + let (response, code) = server.add_api_key(json!({})).await; + + assert_eq!(response, expected_response); + assert_eq!(code, expected_code); + + let (response, code) = server.patch_api_key("content", json!({})).await; + + assert_eq!(response, expected_response); + assert_eq!(code, expected_code); + + let (response, code) = server.get_api_key("content").await; + + assert_eq!(response, expected_response); + assert_eq!(code, expected_code); + + let (response, code) = server.list_api_keys().await; + + assert_eq!(response, expected_response); + assert_eq!(code, expected_code); + + server.use_api_key("MASTER_KEY"); + + let expected_response = json!({"message": "The provided API key is invalid.", + "code": "invalid_api_key", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#invalid_api_key" + }); + let expected_code = 403; + + let (response, code) = server.add_api_key(json!({})).await; + + assert_eq!(response, expected_response); + assert_eq!(code, expected_code); + + let (response, code) = server.patch_api_key("content", json!({})).await; + + assert_eq!(response, expected_response); + assert_eq!(code, expected_code); + + let (response, code) = server.get_api_key("content").await; + + assert_eq!(response, expected_response); + assert_eq!(code, expected_code); + + let (response, code) = server.list_api_keys().await; + + assert_eq!(response, expected_response); + assert_eq!(code, expected_code); +} diff --git a/meilisearch-http/tests/auth/authorization.rs b/meilisearch-http/tests/auth/authorization.rs index 87e7234cc..d8ae8b51e 100644 --- a/meilisearch-http/tests/auth/authorization.rs +++ b/meilisearch-http/tests/auth/authorization.rs @@ -1,4 +1,5 @@ use crate::common::Server; +use chrono::{Duration, Utc}; use maplit::hashmap; use once_cell::sync::Lazy; use serde_json::{json, Value}; @@ -19,7 +20,7 @@ static AUTHORIZATIONS: Lazy> ("PUT", "/indexes/products/") => "indexes.update", ("GET", "/indexes/products/") => "indexes.get", ("DELETE", "/indexes/products/") => "indexes.delete", - ("POST", "/indexes") => "indexes.add", + ("POST", "/indexes") => "indexes.create", ("GET", "/indexes") => "indexes.get", ("GET", "/indexes/products/settings") => "settings.get", ("GET", "/indexes/products/settings/displayed-attributes") => "settings.get", @@ -61,13 +62,15 @@ static INVALID_RESPONSE: Lazy = Lazy::new(|| { #[actix_rt::test] async fn error_access_expired_key() { + use std::{thread, time}; + let mut server = Server::new_auth().await; server.use_api_key("MASTER_KEY"); let content = json!({ "indexes": ["products"], "actions": ALL_ACTIONS.clone(), - "expiresAt": "2020-11-13T00:00:00Z" + "expiresAt": (Utc::now() + Duration::seconds(1)), }); let (response, code) = server.add_api_key(content).await; @@ -77,6 +80,9 @@ async fn error_access_expired_key() { let key = response["key"].as_str().unwrap(); server.use_api_key(&key); + // wait until the key is expired. + thread::sleep(time::Duration::new(1, 0)); + for (method, route) in AUTHORIZATIONS.keys() { let (response, code) = server.dummy_request(method, route).await; @@ -93,7 +99,7 @@ async fn error_access_unauthorized_index() { let content = json!({ "indexes": ["sales"], "actions": ALL_ACTIONS.clone(), - "expiresAt": "2050-11-13T00:00:00Z" + "expiresAt": Utc::now() + Duration::hours(1), }); let (response, code) = server.add_api_key(content).await; @@ -123,7 +129,7 @@ async fn error_access_unauthorized_action() { let content = json!({ "indexes": ["products"], "actions": [], - "expiresAt": "2050-11-13T00:00:00Z" + "expiresAt": Utc::now() + Duration::hours(1), }); let (response, code) = server.add_api_key(content).await; @@ -159,7 +165,7 @@ async fn access_authorized_restricted_index() { let content = json!({ "indexes": ["products"], "actions": [], - "expiresAt": "2050-11-13T00:00:00Z" + "expiresAt": Utc::now() + Duration::hours(1), }); let (response, code) = server.add_api_key(content).await; @@ -210,7 +216,7 @@ async fn access_authorized_no_index_restriction() { let content = json!({ "indexes": ["*"], "actions": [], - "expiresAt": "2050-11-13T00:00:00Z" + "expiresAt": Utc::now() + Duration::hours(1), }); let (response, code) = server.add_api_key(content).await; @@ -272,7 +278,7 @@ async fn access_authorized_stats_restricted_index() { let content = json!({ "indexes": ["products"], "actions": ["stats.get"], - "expiresAt": "2050-11-13T00:00:00Z" + "expiresAt": Utc::now() + Duration::hours(1), }); let (response, code) = server.add_api_key(content).await; assert_eq!(code, 201); @@ -311,7 +317,7 @@ async fn access_authorized_stats_no_index_restriction() { let content = json!({ "indexes": ["*"], "actions": ["stats.get"], - "expiresAt": "2050-11-13T00:00:00Z" + "expiresAt": Utc::now() + Duration::hours(1), }); let (response, code) = server.add_api_key(content).await; assert_eq!(code, 201); @@ -350,7 +356,7 @@ async fn list_authorized_indexes_restricted_index() { let content = json!({ "indexes": ["products"], "actions": ["indexes.get"], - "expiresAt": "2050-11-13T00:00:00Z" + "expiresAt": Utc::now() + Duration::hours(1), }); let (response, code) = server.add_api_key(content).await; assert_eq!(code, 201); @@ -390,7 +396,7 @@ async fn list_authorized_indexes_no_index_restriction() { let content = json!({ "indexes": ["*"], "actions": ["indexes.get"], - "expiresAt": "2050-11-13T00:00:00Z" + "expiresAt": Utc::now() + Duration::hours(1), }); let (response, code) = server.add_api_key(content).await; assert_eq!(code, 201); @@ -410,3 +416,83 @@ async fn list_authorized_indexes_no_index_restriction() { // key should have access on `test` index. assert!(response.iter().any(|index| index["uid"] == "test")); } + +#[actix_rt::test] +async fn list_authorized_tasks_restricted_index() { + let mut server = Server::new_auth().await; + server.use_api_key("MASTER_KEY"); + + // create index `test` + let index = server.index("test"); + let (_, code) = index.create(Some("id")).await; + assert_eq!(code, 202); + // create index `products` + let index = server.index("products"); + let (_, code) = index.create(Some("product_id")).await; + assert_eq!(code, 202); + index.wait_task(0).await; + + // create key with access on `products` index only. + let content = json!({ + "indexes": ["products"], + "actions": ["tasks.get"], + "expiresAt": Utc::now() + Duration::hours(1), + }); + let (response, code) = server.add_api_key(content).await; + assert_eq!(code, 201); + assert!(response["key"].is_string()); + + // use created key. + let key = response["key"].as_str().unwrap(); + server.use_api_key(&key); + + let (response, code) = server.service.get("/tasks").await; + assert_eq!(code, 200); + println!("{}", response); + let response = response["results"].as_array().unwrap(); + // key should have access on `products` index. + assert!(response.iter().any(|task| task["indexUid"] == "products")); + + // key should not have access on `test` index. + assert!(!response.iter().any(|task| task["indexUid"] == "test")); +} + +#[actix_rt::test] +async fn list_authorized_tasks_no_index_restriction() { + let mut server = Server::new_auth().await; + server.use_api_key("MASTER_KEY"); + + // create index `test` + let index = server.index("test"); + let (_, code) = index.create(Some("id")).await; + assert_eq!(code, 202); + // create index `products` + let index = server.index("products"); + let (_, code) = index.create(Some("product_id")).await; + assert_eq!(code, 202); + index.wait_task(0).await; + + // create key with access on all indexes. + let content = json!({ + "indexes": ["*"], + "actions": ["tasks.get"], + "expiresAt": Utc::now() + Duration::hours(1), + }); + let (response, code) = server.add_api_key(content).await; + assert_eq!(code, 201); + assert!(response["key"].is_string()); + + // use created key. + let key = response["key"].as_str().unwrap(); + server.use_api_key(&key); + + let (response, code) = server.service.get("/tasks").await; + assert_eq!(code, 200); + + let response = response["results"].as_array().unwrap(); + // key should have access on `products` index. + assert!(response.iter().any(|task| task["indexUid"] == "products")); + + // key should have access on `test` index. + assert!(response.iter().any(|task| task["indexUid"] == "test")); +} diff --git a/meilisearch-lib/Cargo.toml b/meilisearch-lib/Cargo.toml index f0fd435fb..c378d5f3b 100644 --- a/meilisearch-lib/Cargo.toml +++ b/meilisearch-lib/Cargo.toml @@ -29,6 +29,7 @@ itertools = "0.10.1" lazy_static = "1.4.0" log = "0.4.14" meilisearch-error = { path = "../meilisearch-error" } +meilisearch-auth = { path = "../meilisearch-auth" } milli = { git = "https://github.com/meilisearch/milli.git", tag = "v0.21.0" } mime = "0.3.16" num_cpus = "1.13.0" diff --git a/meilisearch-lib/src/index_controller/dump_actor/error.rs b/meilisearch-lib/src/index_controller/dump_actor/error.rs index 625049fe0..73faf1bbb 100644 --- a/meilisearch-lib/src/index_controller/dump_actor/error.rs +++ b/meilisearch-lib/src/index_controller/dump_actor/error.rs @@ -1,3 +1,4 @@ +use meilisearch_auth::error::AuthControllerError; use meilisearch_error::{internal_error, Code, ErrorCode}; use crate::{index_resolver::error::IndexResolverError, tasks::error::TaskError}; @@ -24,6 +25,7 @@ internal_error!( serde_json::error::Error, tempfile::PersistError, fs_extra::error::Error, + AuthControllerError, TaskError ); diff --git a/meilisearch-lib/src/index_controller/dump_actor/loaders/v4.rs b/meilisearch-lib/src/index_controller/dump_actor/loaders/v4.rs index 1878c3cc3..1c9c5d769 100644 --- a/meilisearch-lib/src/index_controller/dump_actor/loaders/v4.rs +++ b/meilisearch-lib/src/index_controller/dump_actor/loaders/v4.rs @@ -2,6 +2,7 @@ use std::path::Path; use heed::EnvOpenOptions; use log::info; +use meilisearch_auth::AuthController; use crate::analytics; use crate::index_controller::dump_actor::Metadata; @@ -37,6 +38,7 @@ pub fn load_dump( )?; UpdateFileStore::load_dump(src.as_ref(), &dst)?; TaskStore::load_dump(&src, env)?; + AuthController::load_dump(&src, &dst)?; analytics::copy_user_id(src.as_ref(), dst.as_ref()); info!("Loading indexes."); diff --git a/meilisearch-lib/src/index_controller/dump_actor/mod.rs b/meilisearch-lib/src/index_controller/dump_actor/mod.rs index a3b47abe2..6351a3d0d 100644 --- a/meilisearch-lib/src/index_controller/dump_actor/mod.rs +++ b/meilisearch-lib/src/index_controller/dump_actor/mod.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; pub use actor::DumpActor; pub use handle_impl::*; +use meilisearch_auth::AuthController; pub use message::DumpMsg; use tokio::fs::create_dir_all; use tokio::sync::oneshot; @@ -277,6 +278,8 @@ impl DumpJob { .dump(&temp_dump_path, self.update_file_store.clone()) .await?; + AuthController::dump(&self.db_path, &temp_dump_path)?; + let dump_path = tokio::task::spawn_blocking(move || -> Result { // for now we simply copy the updates/updates_files // FIXME: We may copy more files than necessary, if new files are added while we are diff --git a/meilisearch-lib/src/snapshot.rs b/meilisearch-lib/src/snapshot.rs index 556e7fabd..d35922a68 100644 --- a/meilisearch-lib/src/snapshot.rs +++ b/meilisearch-lib/src/snapshot.rs @@ -107,6 +107,7 @@ impl SnapshotJob { self.snapshot_meta_env(temp_snapshot_path)?; self.snapshot_file_store(temp_snapshot_path)?; self.snapshot_indexes(temp_snapshot_path)?; + self.snapshot_auth(temp_snapshot_path)?; let db_name = self .src_path @@ -190,4 +191,18 @@ impl SnapshotJob { Ok(()) } + + fn snapshot_auth(&self, path: &Path) -> anyhow::Result<()> { + let auth_path = self.src_path.join("auth"); + let dst = path.join("auth"); + std::fs::create_dir_all(&dst)?; + let dst = dst.join("data.mdb"); + + let mut options = heed::EnvOpenOptions::new(); + options.map_size(1_073_741_824); + let env = options.open(auth_path)?; + env.copy_to_path(dst, heed::CompactionOption::Enabled)?; + + Ok(()) + } }