diff --git a/Cargo.lock b/Cargo.lock index a1ae6f766..c44891518 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2527,6 +2527,7 @@ dependencies = [ "base64 0.13.1", "enum-iterator", "hmac", + "maplit", "meilisearch-types", "rand", "roaring", diff --git a/dump/src/lib.rs b/dump/src/lib.rs index 6ca3e000e..b1b6807a0 100644 --- a/dump/src/lib.rs +++ b/dump/src/lib.rs @@ -203,12 +203,11 @@ pub(crate) mod test { use big_s::S; use maplit::btreeset; - use meilisearch_types::index_uid::IndexUid; + use meilisearch_types::index_uid_pattern::IndexUidPattern; use meilisearch_types::keys::{Action, Key}; use meilisearch_types::milli::update::Setting; use meilisearch_types::milli::{self}; use meilisearch_types::settings::{Checked, Settings}; - use meilisearch_types::star_or::StarOr; use meilisearch_types::tasks::{Details, Status}; use serde_json::{json, Map, Value}; use time::macros::datetime; @@ -341,7 +340,7 @@ pub(crate) mod test { name: Some(S("doggos_key")), uid: Uuid::from_str("9f8a34da-b6b2-42f0-939b-dbd4c3448655").unwrap(), actions: vec![Action::DocumentsAll], - indexes: vec![StarOr::Other(IndexUid::from_str("doggos").unwrap())], + indexes: vec![IndexUidPattern::from_str("doggos").unwrap()], expires_at: Some(datetime!(4130-03-14 12:21 UTC)), created_at: datetime!(1960-11-15 0:00 UTC), updated_at: datetime!(2022-11-10 0:00 UTC), @@ -351,7 +350,7 @@ pub(crate) mod test { name: Some(S("master_key")), uid: Uuid::from_str("4622f717-1c00-47bb-a494-39d76a49b591").unwrap(), actions: vec![Action::All], - indexes: vec![StarOr::Star], + indexes: vec![IndexUidPattern::all()], expires_at: None, created_at: datetime!(0000-01-01 00:01 UTC), updated_at: datetime!(1964-05-04 17:25 UTC), diff --git a/dump/src/reader/compat/v5_to_v6.rs b/dump/src/reader/compat/v5_to_v6.rs index 6d1e698b3..c98cc819b 100644 --- a/dump/src/reader/compat/v5_to_v6.rs +++ b/dump/src/reader/compat/v5_to_v6.rs @@ -181,10 +181,8 @@ impl CompatV5ToV6 { .indexes .into_iter() .map(|index| match index { - v5::StarOr::Star => v6::StarOr::Star, - v5::StarOr::Other(uid) => { - v6::StarOr::Other(v6::IndexUid::new_unchecked(uid.as_str())) - } + v5::StarOr::Star => v6::IndexUidPattern::all(), + v5::StarOr::Other(uid) => v6::IndexUidPattern::new_unchecked(uid.as_str()), }) .collect(), expires_at: key.expires_at, diff --git a/dump/src/reader/v6/mod.rs b/dump/src/reader/v6/mod.rs index edf552452..f0ad81116 100644 --- a/dump/src/reader/v6/mod.rs +++ b/dump/src/reader/v6/mod.rs @@ -34,8 +34,7 @@ pub type PaginationSettings = meilisearch_types::settings::PaginationSettings; // everything related to the api keys pub type Action = meilisearch_types::keys::Action; -pub type StarOr = meilisearch_types::star_or::StarOr; -pub type IndexUid = meilisearch_types::index_uid::IndexUid; +pub type IndexUidPattern = meilisearch_types::index_uid_pattern::IndexUidPattern; // everything related to the errors pub type ResponseError = meilisearch_types::error::ResponseError; diff --git a/index-scheduler/src/lib.rs b/index-scheduler/src/lib.rs index 387dac2d0..8dd16f961 100644 --- a/index-scheduler/src/lib.rs +++ b/index-scheduler/src/lib.rs @@ -43,6 +43,7 @@ use file_store::FileStore; use meilisearch_types::error::ResponseError; use meilisearch_types::heed::types::{OwnedType, SerdeBincode, SerdeJson, Str}; use meilisearch_types::heed::{self, Database, Env, RoTxn}; +use meilisearch_types::index_uid_pattern::IndexUidPattern; use meilisearch_types::milli; use meilisearch_types::milli::documents::DocumentsBatchBuilder; use meilisearch_types::milli::update::IndexerConfig; @@ -630,7 +631,7 @@ impl IndexScheduler { &self, rtxn: &RoTxn, query: &Query, - authorized_indexes: &Option>, + authorized_indexes: &Option>, ) -> Result { let mut tasks = self.get_task_ids(rtxn, query)?; @@ -648,7 +649,7 @@ impl IndexScheduler { let all_indexes_iter = self.index_tasks.iter(rtxn)?; for result in all_indexes_iter { let (index, index_tasks) = result?; - if !authorized_indexes.contains(&index.to_owned()) { + if !authorized_indexes.iter().any(|p| p.matches_str(index)) { tasks -= index_tasks; } } @@ -668,7 +669,7 @@ impl IndexScheduler { pub fn get_tasks_from_authorized_indexes( &self, query: Query, - authorized_indexes: Option>, + authorized_indexes: Option>, ) -> Result> { let rtxn = self.env.read_txn()?; @@ -2521,7 +2522,11 @@ mod tests { let query = Query { index_uids: Some(vec!["catto".to_owned()]), ..Default::default() }; let tasks = index_scheduler - .get_task_ids_from_authorized_indexes(&rtxn, &query, &Some(vec!["doggo".to_owned()])) + .get_task_ids_from_authorized_indexes( + &rtxn, + &query, + &Some(vec![IndexUidPattern::new_unchecked("doggo")]), + ) .unwrap(); // we have asked for only the tasks associated with catto, but are only authorized to retrieve the tasks // associated with doggo -> empty result @@ -2529,7 +2534,11 @@ mod tests { let query = Query::default(); let tasks = index_scheduler - .get_task_ids_from_authorized_indexes(&rtxn, &query, &Some(vec!["doggo".to_owned()])) + .get_task_ids_from_authorized_indexes( + &rtxn, + &query, + &Some(vec![IndexUidPattern::new_unchecked("doggo")]), + ) .unwrap(); // we asked for all the tasks, but we are only authorized to retrieve the doggo tasks // -> only the index creation of doggo should be returned @@ -2540,7 +2549,10 @@ mod tests { .get_task_ids_from_authorized_indexes( &rtxn, &query, - &Some(vec!["catto".to_owned(), "doggo".to_owned()]), + &Some(vec![ + IndexUidPattern::new_unchecked("catto"), + IndexUidPattern::new_unchecked("doggo"), + ]), ) .unwrap(); // we asked for all the tasks, but we are only authorized to retrieve the doggo and catto tasks @@ -2588,7 +2600,11 @@ mod tests { let query = Query { canceled_by: Some(vec![task_cancelation.uid]), ..Query::default() }; let tasks = index_scheduler - .get_task_ids_from_authorized_indexes(&rtxn, &query, &Some(vec!["doggo".to_string()])) + .get_task_ids_from_authorized_indexes( + &rtxn, + &query, + &Some(vec![IndexUidPattern::new_unchecked("doggo")]), + ) .unwrap(); // Return only 1 because the user is not authorized to see task 2 snapshot!(snapshot_bitmap(&tasks), @"[1,]"); diff --git a/meilisearch-auth/Cargo.toml b/meilisearch-auth/Cargo.toml index 383be69cf..a42cbae02 100644 --- a/meilisearch-auth/Cargo.toml +++ b/meilisearch-auth/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" base64 = "0.13.1" enum-iterator = "1.1.3" hmac = "0.12.1" +maplit = "1.0.2" meilisearch-types = { path = "../meilisearch-types" } rand = "0.8.5" roaring = { version = "0.10.0", features = ["serde"] } diff --git a/meilisearch-auth/src/lib.rs b/meilisearch-auth/src/lib.rs index adfd00ce5..9f9b15f38 100644 --- a/meilisearch-auth/src/lib.rs +++ b/meilisearch-auth/src/lib.rs @@ -7,9 +7,10 @@ use std::path::Path; use std::sync::Arc; use error::{AuthControllerError, Result}; +use maplit::hashset; +use meilisearch_types::index_uid_pattern::IndexUidPattern; use meilisearch_types::keys::{Action, CreateApiKey, Key, PatchApiKey}; use meilisearch_types::milli::update::Setting; -use meilisearch_types::star_or::StarOr; use serde::{Deserialize, Serialize}; pub use store::open_auth_store_env; use store::{generate_key_as_hexa, HeedAuthStore}; @@ -85,29 +86,12 @@ impl AuthController { search_rules: Option, ) -> Result { let mut filters = AuthFilter::default(); - let key = self - .store - .get_api_key(uid)? - .ok_or_else(|| AuthControllerError::ApiKeyNotFound(uid.to_string()))?; + let key = self.get_key(uid)?; - if !key.indexes.iter().any(|i| i == &StarOr::Star) { - filters.search_rules = match search_rules { - // Intersect search_rules with parent key authorized indexes. - Some(search_rules) => SearchRules::Map( - key.indexes - .into_iter() - .filter_map(|index| { - search_rules.get_index_search_rules(&format!("{index}")).map( - |index_search_rules| (index.to_string(), Some(index_search_rules)), - ) - }) - .collect(), - ), - None => SearchRules::Set(key.indexes.into_iter().map(|x| x.to_string()).collect()), - }; - } else if let Some(search_rules) = search_rules { - filters.search_rules = search_rules; - } + filters.search_rules = match search_rules { + Some(search_rules) => search_rules, + None => SearchRules::Set(key.indexes.into_iter().collect()), + }; filters.allow_index_creation = self.is_key_authorized(uid, Action::IndexesAdd, None)?; @@ -150,9 +134,7 @@ impl AuthController { .get_expiration_date(uid, action, None)? .or(match index { // else check if the key has access to the requested index. - Some(index) => { - self.store.get_expiration_date(uid, action, Some(index.as_bytes()))? - } + Some(index) => self.store.get_expiration_date(uid, action, Some(index))?, // or to any index if no index has been requested. None => self.store.prefix_first_expiration_date(uid, action)?, }) { @@ -192,42 +174,54 @@ impl Default for AuthFilter { #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(untagged)] pub enum SearchRules { - Set(HashSet), - Map(HashMap>), + Set(HashSet), + Map(HashMap>), } impl Default for SearchRules { fn default() -> Self { - Self::Set(Some("*".to_string()).into_iter().collect()) + Self::Set(hashset! { IndexUidPattern::all() }) } } impl SearchRules { pub fn is_index_authorized(&self, index: &str) -> bool { match self { - Self::Set(set) => set.contains("*") || set.contains(index), - Self::Map(map) => map.contains_key("*") || map.contains_key(index), + Self::Set(set) => { + set.contains("*") + || set.contains(index) + || set.iter().any(|pattern| pattern.matches_str(index)) + } + Self::Map(map) => { + map.contains_key("*") + || map.contains_key(index) + || map.keys().any(|pattern| pattern.matches_str(index)) + } } } pub fn get_index_search_rules(&self, index: &str) -> Option { match self { - Self::Set(set) => { - if set.contains("*") || set.contains(index) { + Self::Set(_) => { + if self.is_index_authorized(index) { Some(IndexSearchRules::default()) } else { None } } Self::Map(map) => { - map.get(index).or_else(|| map.get("*")).map(|isr| isr.clone().unwrap_or_default()) + // We must take the most retrictive rule of this index uid patterns set of rules. + map.iter() + .filter(|(pattern, _)| pattern.matches_str(index)) + .max_by_key(|(pattern, _)| (pattern.is_exact(), pattern.len())) + .and_then(|(_, rule)| rule.clone()) } } } /// Return the list of indexes such that `self.is_index_authorized(index) == true`, /// or `None` if all indexes satisfy this condition. - pub fn authorized_indexes(&self) -> Option> { + pub fn authorized_indexes(&self) -> Option> { match self { SearchRules::Set(set) => { if set.contains("*") { @@ -248,7 +242,7 @@ impl SearchRules { } impl IntoIterator for SearchRules { - type Item = (String, IndexSearchRules); + type Item = (IndexUidPattern, IndexSearchRules); type IntoIter = Box>; fn into_iter(self) -> Self::IntoIter { diff --git a/meilisearch-auth/src/store.rs b/meilisearch-auth/src/store.rs index c1cec0ede..cc5dcdfb5 100644 --- a/meilisearch-auth/src/store.rs +++ b/meilisearch-auth/src/store.rs @@ -5,20 +5,21 @@ use std::convert::{TryFrom, TryInto}; use std::fs::create_dir_all; use std::path::Path; use std::str; +use std::str::FromStr; use std::sync::Arc; use hmac::{Hmac, Mac}; +use meilisearch_types::index_uid_pattern::IndexUidPattern; use meilisearch_types::keys::KeyId; use meilisearch_types::milli; use meilisearch_types::milli::heed::types::{ByteSlice, DecodeIgnore, SerdeJson}; use meilisearch_types::milli::heed::{Database, Env, EnvOpenOptions, RwTxn}; -use meilisearch_types::star_or::StarOr; use sha2::Sha256; use time::OffsetDateTime; use uuid::fmt::Hyphenated; use uuid::Uuid; -use super::error::Result; +use super::error::{AuthControllerError, Result}; use super::{Action, Key}; const AUTH_STORE_SIZE: usize = 1_073_741_824; //1GiB @@ -129,7 +130,7 @@ impl HeedAuthStore { } } - let no_index_restriction = key.indexes.contains(&StarOr::Star); + let no_index_restriction = key.indexes.iter().any(|p| p.matches_all()); for action in actions { if no_index_restriction { // If there is no index restriction we put None. @@ -214,11 +215,28 @@ impl HeedAuthStore { &self, uid: Uuid, action: Action, - index: Option<&[u8]>, + index: Option<&str>, ) -> Result>> { let rtxn = self.env.read_txn()?; - let tuple = (&uid, &action, index); - Ok(self.action_keyid_index_expiration.get(&rtxn, &tuple)?) + let tuple = (&uid, &action, index.map(|s| s.as_bytes())); + match self.action_keyid_index_expiration.get(&rtxn, &tuple)? { + Some(expiration) => Ok(Some(expiration)), + None => { + let tuple = (&uid, &action, None); + for result in self.action_keyid_index_expiration.prefix_iter(&rtxn, &tuple)? { + let ((_, _, index_uid_pattern), expiration) = result?; + if let Some((pattern, index)) = index_uid_pattern.zip(index) { + let index_uid_pattern = str::from_utf8(pattern)?; + let pattern = IndexUidPattern::from_str(index_uid_pattern) + .map_err(|e| AuthControllerError::Internal(Box::new(e)))?; + if pattern.matches_str(index) { + return Ok(Some(expiration)); + } + } + } + Ok(None) + } + } } pub fn prefix_first_expiration_date( diff --git a/meilisearch-types/src/index_uid_pattern.rs b/meilisearch-types/src/index_uid_pattern.rs new file mode 100644 index 000000000..99537b22f --- /dev/null +++ b/meilisearch-types/src/index_uid_pattern.rs @@ -0,0 +1,124 @@ +use std::borrow::Borrow; +use std::error::Error; +use std::fmt; +use std::ops::Deref; +use std::str::FromStr; + +use deserr::DeserializeFromValue; +use serde::{Deserialize, Serialize}; + +use crate::error::{Code, ErrorCode}; +use crate::index_uid::{IndexUid, IndexUidFormatError}; + +/// An index uid pattern is composed of only ascii alphanumeric characters, - and _, between 1 and 400 +/// bytes long and optionally ending with a *. +#[derive(Serialize, Deserialize, DeserializeFromValue, Debug, Clone, PartialEq, Eq, Hash)] +#[deserr(from(&String) = FromStr::from_str -> IndexUidPatternFormatError)] +pub struct IndexUidPattern(String); + +impl IndexUidPattern { + pub fn new_unchecked(s: impl AsRef) -> Self { + Self(s.as_ref().to_string()) + } + + /// Matches any index name. + pub fn all() -> Self { + IndexUidPattern::from_str("*").unwrap() + } + + /// Returns `true` if it matches any index. + pub fn matches_all(&self) -> bool { + self.0 == "*" + } + + /// Returns `true` if the pattern matches a specific index name. + pub fn is_exact(&self) -> bool { + !self.0.ends_with('*') + } + + /// Returns wether this index uid matches this index uid pattern. + pub fn matches(&self, uid: &IndexUid) -> bool { + self.matches_str(uid.as_str()) + } + + /// Returns wether this string matches this index uid pattern. + pub fn matches_str(&self, uid: &str) -> bool { + match self.0.strip_suffix('*') { + Some(prefix) => uid.starts_with(prefix), + None => self.0 == uid, + } + } +} + +impl Deref for IndexUidPattern { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Borrow for IndexUidPattern { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl TryFrom for IndexUidPattern { + type Error = IndexUidPatternFormatError; + + fn try_from(uid: String) -> Result { + let result = match uid.strip_suffix('*') { + Some("") => Ok(IndexUidPattern(uid)), + Some(prefix) => IndexUid::from_str(prefix).map(|_| IndexUidPattern(uid)), + None => IndexUid::try_from(uid).map(IndexUid::into_inner).map(IndexUidPattern), + }; + + match result { + Ok(index_uid_pattern) => Ok(index_uid_pattern), + Err(IndexUidFormatError { invalid_uid }) => { + Err(IndexUidPatternFormatError { invalid_uid }) + } + } + } +} + +impl FromStr for IndexUidPattern { + type Err = IndexUidPatternFormatError; + + fn from_str(uid: &str) -> Result { + uid.to_string().try_into() + } +} + +impl From for String { + fn from(IndexUidPattern(uid): IndexUidPattern) -> Self { + uid + } +} + +#[derive(Debug)] +pub struct IndexUidPatternFormatError { + pub invalid_uid: String, +} + +impl fmt::Display for IndexUidPatternFormatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "`{}` is not a valid index uid pattern. Index uid patterns \ + can be an integer or a string containing only alphanumeric \ + characters, hyphens (-), underscores (_), and \ + optionally end with a star (*).", + self.invalid_uid, + ) + } +} + +impl Error for IndexUidPatternFormatError {} + +impl ErrorCode for IndexUidPatternFormatError { + fn error_code(&self) -> Code { + Code::InvalidIndexUid + } +} diff --git a/meilisearch-types/src/keys.rs b/meilisearch-types/src/keys.rs index 31561c848..804ee19c6 100644 --- a/meilisearch-types/src/keys.rs +++ b/meilisearch-types/src/keys.rs @@ -2,7 +2,7 @@ use std::convert::Infallible; use std::hash::Hash; use std::str::FromStr; -use deserr::{DeserializeError, DeserializeFromValue, ValuePointerRef}; +use deserr::{DeserializeError, DeserializeFromValue, MergeWithError, ValuePointerRef}; use enum_iterator::Sequence; use milli::update::Setting; use serde::{Deserialize, Serialize}; @@ -12,14 +12,27 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime}; use uuid::Uuid; use crate::deserr::error_messages::immutable_field_error; -use crate::deserr::DeserrJsonError; +use crate::deserr::{DeserrError, DeserrJsonError}; use crate::error::deserr_codes::*; -use crate::error::{unwrap_any, Code, ParseOffsetDateTimeError}; -use crate::index_uid::IndexUid; -use crate::star_or::StarOr; +use crate::error::{unwrap_any, Code, ErrorCode, ParseOffsetDateTimeError}; +use crate::index_uid_pattern::{IndexUidPattern, IndexUidPatternFormatError}; pub type KeyId = Uuid; +impl MergeWithError for DeserrJsonError { + fn merge( + _self_: Option, + other: IndexUidPatternFormatError, + merge_location: deserr::ValuePointerRef, + ) -> std::result::Result { + DeserrError::error::( + None, + deserr::ErrorKind::Unexpected { msg: other.to_string() }, + merge_location, + ) + } +} + #[derive(Debug, DeserializeFromValue)] #[deserr(error = DeserrJsonError, rename_all = camelCase, deny_unknown_fields)] pub struct CreateApiKey { @@ -32,10 +45,11 @@ pub struct CreateApiKey { #[deserr(error = DeserrJsonError, missing_field_error = DeserrJsonError::missing_api_key_actions)] pub actions: Vec, #[deserr(error = DeserrJsonError, missing_field_error = DeserrJsonError::missing_api_key_indexes)] - pub indexes: Vec>, + pub indexes: Vec, #[deserr(error = DeserrJsonError, from(Option) = parse_expiration_date -> ParseOffsetDateTimeError, missing_field_error = DeserrJsonError::missing_api_key_expires_at)] pub expires_at: Option, } + impl CreateApiKey { pub fn to_key(self) -> Key { let CreateApiKey { description, name, uid, actions, indexes, expires_at } = self; @@ -90,7 +104,7 @@ pub struct Key { pub name: Option, pub uid: KeyId, pub actions: Vec, - pub indexes: Vec>, + pub indexes: Vec, #[serde(with = "time::serde::rfc3339::option")] pub expires_at: Option, #[serde(with = "time::serde::rfc3339")] @@ -108,7 +122,7 @@ impl Key { description: Some("Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend".to_string()), uid, actions: vec![Action::All], - indexes: vec![StarOr::Star], + indexes: vec![IndexUidPattern::all()], expires_at: None, created_at: now, updated_at: now, @@ -123,7 +137,7 @@ impl Key { description: Some("Use it to search from the frontend".to_string()), uid, actions: vec![Action::Search], - indexes: vec![StarOr::Star], + indexes: vec![IndexUidPattern::all()], expires_at: None, created_at: now, updated_at: now, diff --git a/meilisearch-types/src/lib.rs b/meilisearch-types/src/lib.rs index 14602b5aa..99c459903 100644 --- a/meilisearch-types/src/lib.rs +++ b/meilisearch-types/src/lib.rs @@ -3,6 +3,7 @@ pub mod deserr; pub mod document_formats; pub mod error; pub mod index_uid; +pub mod index_uid_pattern; pub mod keys; pub mod settings; pub mod star_or; diff --git a/meilisearch/src/extractors/authentication/mod.rs b/meilisearch/src/extractors/authentication/mod.rs index 8944b60d3..84b598689 100644 --- a/meilisearch/src/extractors/authentication/mod.rs +++ b/meilisearch/src/extractors/authentication/mod.rs @@ -199,6 +199,9 @@ pub mod policies { token: &str, index: Option<&str>, ) -> Option { + // A tenant token only has access to the search route which always defines an index. + let index = index?; + // Only search action can be accessed by a tenant token. if A != actions::SEARCH { return None; @@ -206,7 +209,7 @@ pub mod policies { let uid = extract_key_id(token)?; // check if parent key is authorized to do the action. - if auth.is_key_authorized(uid, Action::Search, index).ok()? { + if auth.is_key_authorized(uid, Action::Search, Some(index)).ok()? { // Check if tenant token is valid. let key = auth.generate_key(uid)?; let data = decode::( @@ -217,10 +220,8 @@ pub mod policies { .ok()?; // Check index access if an index restriction is provided. - if let Some(index) = index { - if !data.claims.search_rules.is_index_authorized(index) { - return None; - } + if !data.claims.search_rules.is_index_authorized(index) { + return None; } // Check if token is expired. @@ -230,7 +231,10 @@ pub mod policies { } } - return auth.get_key_filters(uid, Some(data.claims.search_rules)).ok(); + return match auth.get_key_filters(uid, Some(data.claims.search_rules)) { + Ok(auth) if auth.search_rules.is_index_authorized(index) => Some(auth), + _ => None, + }; } None diff --git a/meilisearch/src/routes/api_key.rs b/meilisearch/src/routes/api_key.rs index cd5bfe0c7..efc591d54 100644 --- a/meilisearch/src/routes/api_key.rs +++ b/meilisearch/src/routes/api_key.rs @@ -59,6 +59,7 @@ pub struct ListApiKeys { #[deserr(default = Param(PAGINATION_DEFAULT_LIMIT), error = DeserrQueryParamError)] pub limit: Param, } + impl ListApiKeys { fn as_pagination(self) -> Pagination { Pagination { offset: self.offset.0, limit: self.limit.0 } diff --git a/meilisearch/src/routes/mod.rs b/meilisearch/src/routes/mod.rs index 7aaad7125..0f367c9f4 100644 --- a/meilisearch/src/routes/mod.rs +++ b/meilisearch/src/routes/mod.rs @@ -17,6 +17,8 @@ use crate::analytics::Analytics; use crate::extractors::authentication::policies::*; use crate::extractors::authentication::GuardedData; +const PAGINATION_DEFAULT_LIMIT: usize = 20; + mod api_key; mod dump; pub mod indexes; @@ -34,8 +36,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(web::scope("/swap-indexes").configure(swap_indexes::configure)); } -const PAGINATION_DEFAULT_LIMIT: usize = 20; - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SummarizedTaskView { @@ -59,6 +59,7 @@ impl From for SummarizedTaskView { } } } + pub struct Pagination { pub offset: usize, pub limit: usize, diff --git a/meilisearch/tests/auth/api_keys.rs b/meilisearch/tests/auth/api_keys.rs index 0ae57d726..c3916cd03 100644 --- a/meilisearch/tests/auth/api_keys.rs +++ b/meilisearch/tests/auth/api_keys.rs @@ -377,7 +377,7 @@ async fn error_add_api_key_invalid_index_uids() { meili_snap::snapshot!(code, @"400 Bad Request"); meili_snap::snapshot!(meili_snap::json_string!(response, { ".createdAt" => "[ignored]", ".updatedAt" => "[ignored]" }), @r###" { - "message": "Invalid value at `.indexes[0]`: `invalid index # / \\name with spaces` is not a valid index uid. Index uid can be an integer or a string containing only alphanumeric characters, hyphens (-) and underscores (_).", + "message": "Invalid value at `.indexes[0]`: `invalid index # / \\name with spaces` is not a valid index uid pattern. Index uid patterns can be an integer or a string containing only alphanumeric characters, hyphens (-), underscores (_), and optionally end with a star (*).", "code": "invalid_api_key_indexes", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_api_key_indexes" diff --git a/meilisearch/tests/auth/authorization.rs b/meilisearch/tests/auth/authorization.rs index fae6ee7e1..69a74b022 100644 --- a/meilisearch/tests/auth/authorization.rs +++ b/meilisearch/tests/auth/authorization.rs @@ -77,12 +77,14 @@ static INVALID_RESPONSE: Lazy = Lazy::new(|| { }) }); +const MASTER_KEY: &str = "MASTER_KEY"; + #[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"); + server.use_api_key(MASTER_KEY); let content = json!({ "indexes": ["products"], @@ -111,7 +113,7 @@ async fn error_access_expired_key() { #[actix_rt::test] async fn error_access_unauthorized_index() { let mut server = Server::new_auth().await; - server.use_api_key("MASTER_KEY"); + server.use_api_key(MASTER_KEY); let content = json!({ "indexes": ["sales"], @@ -144,7 +146,7 @@ async fn error_access_unauthorized_action() { for ((method, route), action) in AUTHORIZATIONS.iter() { // create a new API key letting only the needed action. - server.use_api_key("MASTER_KEY"); + server.use_api_key(MASTER_KEY); let content = json!({ "indexes": ["products"], @@ -168,7 +170,7 @@ async fn error_access_unauthorized_action() { #[actix_rt::test] async fn access_authorized_master_key() { let mut server = Server::new_auth().await; - server.use_api_key("MASTER_KEY"); + server.use_api_key(MASTER_KEY); // master key must have access to all routes. for ((method, route), _) in AUTHORIZATIONS.iter() { @@ -185,7 +187,7 @@ async fn access_authorized_restricted_index() { for ((method, route), actions) in AUTHORIZATIONS.iter() { for action in actions { // create a new API key letting only the needed action. - server.use_api_key("MASTER_KEY"); + server.use_api_key(MASTER_KEY); let content = json!({ "indexes": ["products"], @@ -222,7 +224,7 @@ async fn access_authorized_no_index_restriction() { for ((method, route), actions) in AUTHORIZATIONS.iter() { for action in actions { // create a new API key letting only the needed action. - server.use_api_key("MASTER_KEY"); + server.use_api_key(MASTER_KEY); let content = json!({ "indexes": ["*"], @@ -255,7 +257,7 @@ async fn access_authorized_no_index_restriction() { #[actix_rt::test] async fn access_authorized_stats_restricted_index() { let mut server = Server::new_auth().await; - server.use_admin_key("MASTER_KEY").await; + server.use_admin_key(MASTER_KEY).await; // create index `test` let index = server.index("test"); @@ -295,7 +297,7 @@ async fn access_authorized_stats_restricted_index() { #[actix_rt::test] async fn access_authorized_stats_no_index_restriction() { let mut server = Server::new_auth().await; - server.use_admin_key("MASTER_KEY").await; + server.use_admin_key(MASTER_KEY).await; // create index `test` let index = server.index("test"); @@ -335,7 +337,7 @@ async fn access_authorized_stats_no_index_restriction() { #[actix_rt::test] async fn list_authorized_indexes_restricted_index() { let mut server = Server::new_auth().await; - server.use_admin_key("MASTER_KEY").await; + server.use_admin_key(MASTER_KEY).await; // create index `test` let index = server.index("test"); @@ -376,7 +378,7 @@ async fn list_authorized_indexes_restricted_index() { #[actix_rt::test] async fn list_authorized_indexes_no_index_restriction() { let mut server = Server::new_auth().await; - server.use_admin_key("MASTER_KEY").await; + server.use_admin_key(MASTER_KEY).await; // create index `test` let index = server.index("test"); @@ -414,10 +416,194 @@ async fn list_authorized_indexes_no_index_restriction() { assert!(response.iter().any(|index| index["uid"] == "test")); } +#[actix_rt::test] +async fn access_authorized_index_patterns() { + let mut server = Server::new_auth().await; + server.use_admin_key(MASTER_KEY).await; + + // create products_1 index + let index_1 = server.index("products_1"); + let (response, code) = index_1.create(Some("id")).await; + assert_eq!(202, code, "{:?}", &response); + + // create products index + let index_ = server.index("products"); + let (response, code) = index_.create(Some("id")).await; + assert_eq!(202, code, "{:?}", &response); + + // create key with all document access on indices with product_* pattern. + let content = json!({ + "indexes": ["products_*"], + "actions": ["documents.*"], + "expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(), + }); + + // Register the key + let (response, code) = server.add_api_key(content).await; + assert_eq!(201, code, "{:?}", &response); + assert!(response["key"].is_string()); + + // use created key. + let key = response["key"].as_str().unwrap(); + server.use_api_key(key); + + // refer to products_1 and products with modified api key. + let index_1 = server.index("products_1"); + + let index_ = server.index("products"); + + // try to create a index via add documents route + let documents = json!([ + { + "id": 1, + "content": "foo", + } + ]); + + // Adding document to products_1 index. Should succeed with 202 + let (response, code) = index_1.add_documents(documents.clone(), None).await; + assert_eq!(202, code, "{:?}", &response); + let task_id = response["taskUid"].as_u64().unwrap(); + + // Adding document to products index. Should Fail with 403 -- invalid_api_key + let (response, code) = index_.add_documents(documents, None).await; + assert_eq!(403, code, "{:?}", &response); + + server.use_api_key(MASTER_KEY); + + // refer to products_1 with modified api key. + let index_1 = server.index("products_1"); + + index_1.wait_task(task_id).await; + + let (response, code) = index_1.get_task(task_id).await; + assert_eq!(200, code, "{:?}", &response); + assert_eq!(response["status"], "succeeded"); +} + +#[actix_rt::test] +async fn raise_error_non_authorized_index_patterns() { + let mut server = Server::new_auth().await; + server.use_admin_key(MASTER_KEY).await; + + // create products_1 index + let product_1_index = server.index("products_1"); + let (response, code) = product_1_index.create(Some("id")).await; + assert_eq!(202, code, "{:?}", &response); + + // create products_2 index + let product_2_index = server.index("products_2"); + let (response, code) = product_2_index.create(Some("id")).await; + assert_eq!(202, code, "{:?}", &response); + + // create test index + let test_index = server.index("test"); + let (response, code) = test_index.create(Some("id")).await; + assert_eq!(202, code, "{:?}", &response); + + // create key with all document access on indices with product_* pattern. + let content = json!({ + "indexes": ["products_*"], + "actions": ["documents.*"], + "expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(), + }); + + // Register the key + let (response, code) = server.add_api_key(content).await; + assert_eq!(201, code, "{:?}", &response); + assert!(response["key"].is_string()); + + // use created key. + let key = response["key"].as_str().unwrap(); + server.use_api_key(key); + + // refer to products_1 and products_2 with modified api key. + let product_1_index = server.index("products_1"); + let product_2_index = server.index("products_2"); + + // refer to test index + let test_index = server.index("test"); + + // try to create a index via add documents route + let documents = json!([ + { + "id": 1, + "content": "foo", + } + ]); + + // Adding document to products_1 index. Should succeed with 202 + let (response, code) = product_1_index.add_documents(documents.clone(), None).await; + assert_eq!(202, code, "{:?}", &response); + let task1_id = response["taskUid"].as_u64().unwrap(); + + // Adding document to products_2 index. Should succeed with 202 + let (response, code) = product_2_index.add_documents(documents.clone(), None).await; + assert_eq!(202, code, "{:?}", &response); + let task2_id = response["taskUid"].as_u64().unwrap(); + + // Adding document to test index. Should Fail with 403 -- invalid_api_key + let (response, code) = test_index.add_documents(documents, None).await; + assert_eq!(403, code, "{:?}", &response); + + server.use_api_key(MASTER_KEY); + + // refer to products_1 with modified api key. + let product_1_index = server.index("products_1"); + // refer to products_2 with modified api key. + let product_2_index = server.index("products_2"); + + product_1_index.wait_task(task1_id).await; + product_2_index.wait_task(task2_id).await; + + let (response, code) = product_1_index.get_task(task1_id).await; + assert_eq!(200, code, "{:?}", &response); + assert_eq!(response["status"], "succeeded"); + + let (response, code) = product_1_index.get_task(task2_id).await; + assert_eq!(200, code, "{:?}", &response); + assert_eq!(response["status"], "succeeded"); +} + +#[actix_rt::test] +async fn pattern_indexes() { + // Create server with master key + let mut server = Server::new_auth().await; + server.use_admin_key(MASTER_KEY).await; + + // index.* constraints on products_* index pattern + let content = json!({ + "indexes": ["products_*"], + "actions": ["indexes.*"], + "expiresAt": (OffsetDateTime::now_utc() + Duration::hours(1)).format(&Rfc3339).unwrap(), + }); + + // Generate and use the api key + let (response, code) = server.add_api_key(content).await; + assert_eq!(201, code, "{:?}", &response); + let key = response["key"].as_str().expect("Key is not string"); + server.use_api_key(key); + + // Create Index products_1 using generated api key + let products_1 = server.index("products_1"); + let (response, code) = products_1.create(Some("id")).await; + assert_eq!(202, code, "{:?}", &response); + + // Fail to create products_* using generated api key + let products_1 = server.index("products_*"); + let (response, code) = products_1.create(Some("id")).await; + assert_eq!(400, code, "{:?}", &response); + + // Fail to create test_1 using generated api key + let products_1 = server.index("test_1"); + let (response, code) = products_1.create(Some("id")).await; + assert_eq!(403, code, "{:?}", &response); +} + #[actix_rt::test] async fn list_authorized_tasks_restricted_index() { let mut server = Server::new_auth().await; - server.use_admin_key("MASTER_KEY").await; + server.use_admin_key(MASTER_KEY).await; // create index `test` let index = server.index("test"); @@ -446,7 +632,6 @@ async fn list_authorized_tasks_restricted_index() { let (response, code) = server.service.get("/tasks").await; assert_eq!(200, code, "{:?}", &response); - println!("{}", response); let response = response["results"].as_array().unwrap(); // key should have access on `products` index. assert!(response.iter().any(|task| task["indexUid"] == "products")); @@ -458,7 +643,7 @@ async fn list_authorized_tasks_restricted_index() { #[actix_rt::test] async fn list_authorized_tasks_no_index_restriction() { let mut server = Server::new_auth().await; - server.use_admin_key("MASTER_KEY").await; + server.use_admin_key(MASTER_KEY).await; // create index `test` let index = server.index("test"); @@ -499,7 +684,7 @@ async fn list_authorized_tasks_no_index_restriction() { #[actix_rt::test] async fn error_creating_index_without_action() { let mut server = Server::new_auth().await; - server.use_api_key("MASTER_KEY"); + server.use_api_key(MASTER_KEY); // create key with access on all indexes. let content = json!({ @@ -587,7 +772,7 @@ async fn lazy_create_index() { ]; for content in contents { - server.use_api_key("MASTER_KEY"); + server.use_api_key(MASTER_KEY); let (response, code) = server.add_api_key(content).await; assert_eq!(201, code, "{:?}", &response); assert!(response["key"].is_string()); @@ -643,14 +828,114 @@ async fn lazy_create_index() { } } +#[actix_rt::test] +async fn lazy_create_index_from_pattern() { + let mut server = Server::new_auth().await; + + // create key with access on all indexes. + let contents = vec![ + json!({ + "indexes": ["products_*"], + "actions": ["*"], + "expiresAt": "2050-11-13T00:00:00Z" + }), + json!({ + "indexes": ["products_*"], + "actions": ["indexes.*", "documents.*", "settings.*", "tasks.*"], + "expiresAt": "2050-11-13T00:00:00Z" + }), + json!({ + "indexes": ["products_*"], + "actions": ["indexes.create", "documents.add", "settings.update", "tasks.get"], + "expiresAt": "2050-11-13T00:00:00Z" + }), + ]; + + for content in contents { + server.use_api_key(MASTER_KEY); + let (response, code) = server.add_api_key(content).await; + assert_eq!(201, code, "{:?}", &response); + assert!(response["key"].is_string()); + + // use created key. + let key = response["key"].as_str().unwrap(); + server.use_api_key(key); + + // try to create a index via add documents route + let index = server.index("products_1"); + let test = server.index("test"); + let documents = json!([ + { + "id": 1, + "content": "foo", + } + ]); + + let (response, code) = index.add_documents(documents.clone(), None).await; + assert_eq!(202, code, "{:?}", &response); + let task_id = response["taskUid"].as_u64().unwrap(); + + index.wait_task(task_id).await; + + let (response, code) = index.get_task(task_id).await; + assert_eq!(200, code, "{:?}", &response); + assert_eq!(response["status"], "succeeded"); + + // Fail to create test index + let (response, code) = test.add_documents(documents, None).await; + assert_eq!(403, code, "{:?}", &response); + + // try to create a index via add settings route + let index = server.index("products_2"); + let settings = json!({ "distinctAttribute": "test"}); + + let (response, code) = index.update_settings(settings).await; + assert_eq!(202, code, "{:?}", &response); + let task_id = response["taskUid"].as_u64().unwrap(); + + index.wait_task(task_id).await; + + let (response, code) = index.get_task(task_id).await; + assert_eq!(200, code, "{:?}", &response); + assert_eq!(response["status"], "succeeded"); + + // Fail to create test index + + let index = server.index("test"); + let settings = json!({ "distinctAttribute": "test"}); + + let (response, code) = index.update_settings(settings).await; + assert_eq!(403, code, "{:?}", &response); + + // try to create a index via add specialized settings route + let index = server.index("products_3"); + let (response, code) = index.update_distinct_attribute(json!("test")).await; + assert_eq!(202, code, "{:?}", &response); + let task_id = response["taskUid"].as_u64().unwrap(); + + index.wait_task(task_id).await; + + let (response, code) = index.get_task(task_id).await; + assert_eq!(200, code, "{:?}", &response); + assert_eq!(response["status"], "succeeded"); + + // Fail to create test index + let index = server.index("test"); + let settings = json!({ "distinctAttribute": "test"}); + + let (response, code) = index.update_settings(settings).await; + assert_eq!(403, code, "{:?}", &response); + } +} + #[actix_rt::test] async fn error_creating_index_without_index() { let mut server = Server::new_auth().await; - server.use_api_key("MASTER_KEY"); + server.use_api_key(MASTER_KEY); // create key with access on all indexes. let content = json!({ - "indexes": ["unexpected"], + "indexes": ["unexpected","products_*"], "actions": ["*"], "expiresAt": "2050-11-13T00:00:00Z" }); @@ -690,4 +975,32 @@ async fn error_creating_index_without_index() { let index = server.index("test3"); let (response, code) = index.create(None).await; assert_eq!(403, code, "{:?}", &response); + + // try to create a index via add documents route + let index = server.index("products"); + let documents = json!([ + { + "id": 1, + "content": "foo", + } + ]); + + let (response, code) = index.add_documents(documents, None).await; + assert_eq!(403, code, "{:?}", &response); + + // try to create a index via add settings route + let index = server.index("products"); + let settings = json!({ "distinctAttribute": "test"}); + let (response, code) = index.update_settings(settings).await; + assert_eq!(403, code, "{:?}", &response); + + // try to create a index via add specialized settings route + let index = server.index("products"); + let (response, code) = index.update_distinct_attribute(json!("test")).await; + assert_eq!(403, code, "{:?}", &response); + + // try to create a index via create index route + let index = server.index("products"); + let (response, code) = index.create(None).await; + assert_eq!(403, code, "{:?}", &response); } diff --git a/meilisearch/tests/auth/errors.rs b/meilisearch/tests/auth/errors.rs index 2ef853d72..0bfef878b 100644 --- a/meilisearch/tests/auth/errors.rs +++ b/meilisearch/tests/auth/errors.rs @@ -120,7 +120,7 @@ async fn create_api_key_bad_indexes() { snapshot!(code, @"400 Bad Request"); snapshot!(json_string!(response), @r###" { - "message": "Invalid value at `.indexes[0]`: `good doggo` is not a valid index uid. Index uid can be an integer or a string containing only alphanumeric characters, hyphens (-) and underscores (_).", + "message": "Invalid value at `.indexes[0]`: `good doggo` is not a valid index uid pattern. Index uid patterns can be an integer or a string containing only alphanumeric characters, hyphens (-), underscores (_), and optionally end with a star (*).", "code": "invalid_api_key_indexes", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_api_key_indexes" diff --git a/meilisearch/tests/auth/tenant_token.rs b/meilisearch/tests/auth/tenant_token.rs index fbf9d2b49..3992a9aed 100644 --- a/meilisearch/tests/auth/tenant_token.rs +++ b/meilisearch/tests/auth/tenant_token.rs @@ -82,6 +82,11 @@ static ACCEPTED_KEYS: Lazy> = Lazy::new(|| { "actions": ["search"], "expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap() }), + json!({ + "indexes": ["sal*", "prod*"], + "actions": ["search"], + "expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap() + }), ] }); @@ -104,6 +109,11 @@ static REFUSED_KEYS: Lazy> = Lazy::new(|| { "actions": ["*"], "expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap() }), + json!({ + "indexes": ["prod*", "p*"], + "actions": ["*"], + "expiresAt": (OffsetDateTime::now_utc() + Duration::days(1)).format(&Rfc3339).unwrap() + }), json!({ "indexes": ["products"], "actions": ["search"], @@ -245,6 +255,10 @@ async fn search_authorized_simple_token() { "searchRules" => json!(["sales"]), "exp" => Value::Null }, + hashmap! { + "searchRules" => json!(["sa*"]), + "exp" => Value::Null + }, ]; compute_authorized_search!(tenant_tokens, {}, 5); @@ -351,11 +365,19 @@ async fn filter_search_authorized_filter_token() { }), "exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp()) }, + hashmap! { + "searchRules" => json!({ + "*": {}, + "sal*": {"filter": ["color = blue"]} + }), + "exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp()) + }, ]; compute_authorized_search!(tenant_tokens, "color = yellow", 1); } +/// Tests that those Tenant Token are incompatible with the REFUSED_KEYS defined above. #[actix_rt::test] async fn error_search_token_forbidden_parent_key() { let tenant_tokens = vec![ @@ -383,6 +405,10 @@ async fn error_search_token_forbidden_parent_key() { "searchRules" => json!(["sales"]), "exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp()) }, + hashmap! { + "searchRules" => json!(["sali*", "s*", "sales*"]), + "exp" => json!((OffsetDateTime::now_utc() + Duration::hours(1)).unix_timestamp()) + }, ]; compute_forbidden_search!(tenant_tokens, REFUSED_KEYS);