diff --git a/meilisearch-auth/src/lib.rs b/meilisearch-auth/src/lib.rs index e74f1707c..4dbf1bf6f 100644 --- a/meilisearch-auth/src/lib.rs +++ b/meilisearch-auth/src/lib.rs @@ -188,6 +188,12 @@ impl AuthFilter { self.allow_index_creation && self.is_index_authorized(index) } + #[inline] + /// Return true if a tenant token was used to generate the search rules. + pub fn is_tenant_token(&self) -> bool { + self.search_rules.is_some() + } + pub fn with_allowed_indexes(allowed_indexes: HashSet) -> Self { Self { search_rules: None, @@ -205,6 +211,7 @@ impl AuthFilter { .unwrap_or(true) } + /// Check if the index is authorized by the API key and the tenant token. pub fn is_index_authorized(&self, index: &str) -> bool { self.key_authorized_indexes.is_index_authorized(index) && self @@ -214,6 +221,44 @@ impl AuthFilter { .unwrap_or(true) } + /// Only check if the index is authorized by the API key + pub fn api_key_is_index_authorized(&self, index: &str) -> bool { + self.key_authorized_indexes.is_index_authorized(index) + } + + /// Only check if the index is authorized by the tenant token + pub fn tenant_token_is_index_authorized(&self, index: &str) -> bool { + self.search_rules + .as_ref() + .map(|search_rules| search_rules.is_index_authorized(index)) + .unwrap_or(true) + } + + /// Return the list of authorized indexes by the tenant token if any + pub fn tenant_token_list_index_authorized(&self) -> Vec { + match self.search_rules { + Some(ref search_rules) => { + let mut indexes: Vec<_> = match search_rules { + SearchRules::Set(set) => set.iter().map(|s| s.to_string()).collect(), + SearchRules::Map(map) => map.keys().map(|s| s.to_string()).collect(), + }; + indexes.sort_unstable(); + indexes + } + None => Vec::new(), + } + } + + /// Return the list of authorized indexes by the api key if any + pub fn api_key_list_index_authorized(&self) -> Vec { + let mut indexes: Vec<_> = match self.key_authorized_indexes { + SearchRules::Set(ref set) => set.iter().map(|s| s.to_string()).collect(), + SearchRules::Map(ref map) => map.keys().map(|s| s.to_string()).collect(), + }; + indexes.sort_unstable(); + indexes + } + pub fn get_index_search_rules(&self, index: &str) -> Option { if !self.is_index_authorized(index) { return None; diff --git a/meilisearch/src/extractors/authentication/mod.rs b/meilisearch/src/extractors/authentication/mod.rs index c3c38c27f..ecd1cadf8 100644 --- a/meilisearch/src/extractors/authentication/mod.rs +++ b/meilisearch/src/extractors/authentication/mod.rs @@ -136,6 +136,7 @@ pub mod policies { use actix_web::web::Data; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use meilisearch_auth::{AuthController, AuthFilter, SearchRules}; + use meilisearch_types::error::{Code, ErrorCode}; // reexport actions in policies in order to be used in routes configuration. pub use meilisearch_types::keys::{actions, Action}; use serde::{Deserialize, Serialize}; @@ -151,14 +152,26 @@ pub mod policies { #[derive(thiserror::Error, Debug)] pub enum AuthError { - #[error("Tenant token expired. Was valid up to `{exp}` and we're now `{now}`")] + #[error("Tenant token expired. Was valid up to `{exp}` and we're now `{now}`.")] ExpiredTenantToken { exp: i64, now: i64 }, #[error("The provided API key is invalid.")] InvalidApiKey, + #[error("The provided tenant token cannot acces the index `{index}`, allowed indexes are {allowed:?}.")] + TenantTokenAccessingnUnauthorizedIndex { index: String, allowed: Vec }, + #[error( + "The API key used to generate this tenant token cannot acces the index `{index}`." + )] + TenantTokenApiKeyAccessingnUnauthorizedIndex { index: String }, + #[error( + "The API key cannot acces the index `{index}`, authorized indexes are {allowed:?}." + )] + ApiKeyAccessingnUnauthorizedIndex { index: String, allowed: Vec }, #[error("The provided tenant token is invalid.")] InvalidTenantToken, - #[error("Could not decode tenant token, {0}")] + #[error("Could not decode tenant token, {0}.")] CouldNotDecodeTenantToken(jsonwebtoken::errors::Error), + #[error("Invalid action `{0}`.")] + InternalInvalidAction(u8), } impl From for AuthError { @@ -172,6 +185,15 @@ pub mod policies { } } + impl ErrorCode for AuthError { + fn error_code(&self) -> Code { + match self { + AuthError::InternalInvalidAction(_) => Code::Internal, + _ => Code::InvalidApiKey, + } + } + } + fn tenant_token_validation() -> Validation { let mut validation = Validation::default(); validation.validate_exp = false; @@ -233,13 +255,37 @@ pub mod policies { }; // check that the indexes are allowed - let action = Action::from_repr(A).ok_or(AuthError::InvalidTenantToken)?; + let action = Action::from_repr(A).ok_or(AuthError::InternalInvalidAction(A))?; let auth_filter = auth .get_key_filters(key_uuid, search_rules) - .map_err(|_e| AuthError::InvalidTenantToken)?; - if auth.is_key_authorized(key_uuid, action, index).unwrap_or(false) - && index.map(|index| auth_filter.is_index_authorized(index)).unwrap_or(true) - { + .map_err(|_e| AuthError::InvalidApiKey)?; + + // First check if the index is authorized in the tenant token, this is a public + // information, we can return a nice error message. + if let Some(index) = index { + if !auth_filter.tenant_token_is_index_authorized(index) { + return Err(AuthError::TenantTokenAccessingnUnauthorizedIndex { + index: index.to_string(), + allowed: auth_filter.tenant_token_list_index_authorized(), + }); + } + if !auth_filter.api_key_is_index_authorized(index) { + if auth_filter.is_tenant_token() { + // If the error comes from a tenant token we cannot share the list + // of authorized indexes in the API key. This is not public information. + return Err(AuthError::TenantTokenApiKeyAccessingnUnauthorizedIndex { + index: index.to_string(), + }); + } else { + // Otherwise we can share the + return Err(AuthError::ApiKeyAccessingnUnauthorizedIndex { + index: index.to_string(), + allowed: auth_filter.api_key_list_index_authorized(), + }); + } + } + } + if auth.is_key_authorized(key_uuid, action, index).unwrap_or(false) { return Ok(auth_filter); } @@ -263,7 +309,6 @@ pub mod policies { let key = if let Some(key) = auth.generate_key(uid) { key } else { - /// Only happens when no master key has been set return Err(AuthError::InvalidTenantToken); }; diff --git a/meilisearch/tests/auth/authorization.rs b/meilisearch/tests/auth/authorization.rs index a57c9e11d..609b7d01b 100644 --- a/meilisearch/tests/auth/authorization.rs +++ b/meilisearch/tests/auth/authorization.rs @@ -78,7 +78,7 @@ pub static ALL_ACTIONS: Lazy> = Lazy::new(|| { }); static INVALID_RESPONSE: Lazy = Lazy::new(|| { - json!({"message": "The provided API key is invalid.", + json!({"message": null, "code": "invalid_api_key", "type": "auth", "link": "https://docs.meilisearch.com/errors#invalid_api_key" @@ -119,7 +119,8 @@ async fn error_access_expired_key() { thread::sleep(time::Duration::new(1, 0)); for (method, route) in AUTHORIZATIONS.keys() { - let (response, code) = server.dummy_request(method, route).await; + let (mut response, code) = server.dummy_request(method, route).await; + response["message"] = serde_json::json!(null); assert_eq!(response, INVALID_RESPONSE.clone(), "on route: {:?} - {:?}", method, route); assert_eq!(403, code, "{:?}", &response); @@ -149,7 +150,8 @@ async fn error_access_unauthorized_index() { // filter `products` index routes .filter(|(_, route)| route.starts_with("/indexes/products")) { - let (response, code) = server.dummy_request(method, route).await; + let (mut response, code) = server.dummy_request(method, route).await; + response["message"] = serde_json::json!(null); assert_eq!(response, INVALID_RESPONSE.clone(), "on route: {:?} - {:?}", method, route); assert_eq!(403, code, "{:?}", &response); @@ -176,7 +178,8 @@ async fn error_access_unauthorized_action() { let key = response["key"].as_str().unwrap(); server.use_api_key(key); - let (response, code) = server.dummy_request(method, route).await; + let (mut response, code) = server.dummy_request(method, route).await; + response["message"] = serde_json::json!(null); assert_eq!(response, INVALID_RESPONSE.clone(), "on route: {:?} - {:?}", method, route); assert_eq!(403, code, "{:?}", &response); diff --git a/meilisearch/tests/auth/errors.rs b/meilisearch/tests/auth/errors.rs index 4c65fce98..466eefe65 100644 --- a/meilisearch/tests/auth/errors.rs +++ b/meilisearch/tests/auth/errors.rs @@ -478,6 +478,21 @@ async fn invalid_auth_format() { } "###); + let req = test::TestRequest::get().uri("/indexes/dog/documents").to_request(); + let res = test::call_service(&app, req).await; + let status_code = res.status(); + let body = test::read_body(res).await; + let response: Value = serde_json::from_slice(&body).unwrap_or_default(); + snapshot!(status_code, @"401 Unauthorized"); + snapshot!(response, @r###" + { + "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 (response, status_code) = send_request_with_custom_auth(&app, "/indexes/dog/documents", "Bearer").await; snapshot!(status_code, @"403 Forbidden"); @@ -489,9 +504,15 @@ async fn invalid_auth_format() { "link": "https://docs.meilisearch.com/errors#invalid_api_key" } "###); +} + +#[actix_rt::test] +async fn invalid_api_key() { + let server = Server::new_auth().await; + let app = server.init_web_app().await; let (response, status_code) = - send_request_with_custom_auth(&app, "/indexes/dog/documents", "Bearer kefir").await; + send_request_with_custom_auth(&app, "/indexes/dog/search", "Bearer kefir").await; snapshot!(status_code, @"403 Forbidden"); snapshot!(response, @r###" { @@ -502,6 +523,54 @@ async fn invalid_auth_format() { } "###); + let uuid = Uuid::nil(); + let key = json!({ "actions": ["search"], "indexes": ["dog"], "expiresAt": null, "uid": uuid.to_string() }); + let req = test::TestRequest::post() + .uri("/keys") + .insert_header(("Authorization", "Bearer MASTER_KEY")) + .set_json(&key) + .to_request(); + let res = test::call_service(&app, req).await; + let body = test::read_body(res).await; + let response: Value = serde_json::from_slice(&body).unwrap_or_default(); + snapshot!(json_string!(response, { ".createdAt" => "[date]", ".updatedAt" => "[date]" }), @r###" + { + "name": null, + "description": null, + "key": "aeb94973e0b6e912d94165430bbe87dee91a7c4f891ce19050c3910ec96977e9", + "uid": "00000000-0000-0000-0000-000000000000", + "actions": [ + "search" + ], + "indexes": [ + "dog" + ], + "expiresAt": null, + "createdAt": "[date]", + "updatedAt": "[date]" + } + "###); + let key = response["key"].as_str().unwrap(); + + let (response, status_code) = + send_request_with_custom_auth(&app, "/indexes/doggo/search", &format!("Bearer {key}")) + .await; + snapshot!(status_code, @"403 Forbidden"); + snapshot!(response, @r###" + { + "message": "The API key cannot acces the index `doggo`, authorized indexes are [\"dog\"].", + "code": "invalid_api_key", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#invalid_api_key" + } + "###); +} + +#[actix_rt::test] +async fn invalid_tenant_token() { + let server = Server::new_auth().await; + let app = server.init_web_app().await; + // The tenant token won't be recognized at all if we're not on a search route let claims = json!({ "tamo": "kefir" }); let jwt = jsonwebtoken::encode(&Header::default(), &claims, &EncodingKey::from_secret(b"tamo")) @@ -527,7 +596,7 @@ async fn invalid_auth_format() { snapshot!(status_code, @"403 Forbidden"); snapshot!(response, @r###" { - "message": "Could not decode tenant token, JSON error: missing field `searchRules` at line 1 column 16", + "message": "Could not decode tenant token, JSON error: missing field `searchRules` at line 1 column 16.", "code": "invalid_api_key", "type": "auth", "link": "https://docs.meilisearch.com/errors#invalid_api_key" @@ -543,7 +612,7 @@ async fn invalid_auth_format() { snapshot!(status_code, @"403 Forbidden"); snapshot!(response, @r###" { - "message": "Could not decode tenant token, JSON error: data did not match any variant of untagged enum SearchRules at line 1 column 23", + "message": "Could not decode tenant token, JSON error: data did not match any variant of untagged enum SearchRules at line 1 column 23.", "code": "invalid_api_key", "type": "auth", "link": "https://docs.meilisearch.com/errors#invalid_api_key" @@ -559,12 +628,73 @@ async fn invalid_auth_format() { snapshot!(status_code, @"403 Forbidden"); snapshot!(response, @r###" { - "message": "Could not decode tenant token, InvalidSignature", + "message": "Could not decode tenant token, InvalidSignature.", "code": "invalid_api_key", "type": "auth", "link": "https://docs.meilisearch.com/errors#invalid_api_key" } "###); - // ~~ For the next tests we first need to retrieve an API key + // ~~ For the next tests we first need a valid API key + let key = json!({ "actions": ["search"], "indexes": ["dog"], "expiresAt": null, "uid": uuid.to_string() }); + let req = test::TestRequest::post() + .uri("/keys") + .insert_header(("Authorization", "Bearer MASTER_KEY")) + .set_json(&key) + .to_request(); + let res = test::call_service(&app, req).await; + let body = test::read_body(res).await; + let response: Value = serde_json::from_slice(&body).unwrap_or_default(); + snapshot!(json_string!(response, { ".createdAt" => "[date]", ".updatedAt" => "[date]" }), @r###" + { + "name": null, + "description": null, + "key": "aeb94973e0b6e912d94165430bbe87dee91a7c4f891ce19050c3910ec96977e9", + "uid": "00000000-0000-0000-0000-000000000000", + "actions": [ + "search" + ], + "indexes": [ + "dog" + ], + "expiresAt": null, + "createdAt": "[date]", + "updatedAt": "[date]" + } + "###); + let key = response["key"].as_str().unwrap(); + + let claims = json!({ "searchRules": ["doggo", "catto"], "apiKeyUid": uuid.to_string() }); + let jwt = jsonwebtoken::encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(key.as_bytes()), + ) + .unwrap(); + // Try to access an index that is not authorized by the tenant token + let (response, status_code) = + send_request_with_custom_auth(&app, "/indexes/dog/search", &format!("Bearer {jwt}")).await; + snapshot!(status_code, @"403 Forbidden"); + snapshot!(response, @r###" + { + "message": "The provided tenant token cannot acces the index `dog`, allowed indexes are [\"catto\", \"doggo\"].", + "code": "invalid_api_key", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#invalid_api_key" + } + "###); + + // Try to access an index that *is* authorized by the tenant token but not by the api key used to generate the tt + let (response, status_code) = + send_request_with_custom_auth(&app, "/indexes/doggo/search", &format!("Bearer {jwt}")) + .await; + snapshot!(status_code, @"403 Forbidden"); + snapshot!(response, @r###" + { + "message": "The API key used to generate this tenant token cannot acces the index `doggo`.", + "code": "invalid_api_key", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#invalid_api_key" + } + "###); }